├── .github └── FUNDING.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── api │ ├── context.go │ ├── errors.go │ ├── healthcheck.go │ ├── helpers.go │ ├── main.go │ ├── middleware.go │ ├── routes.go │ ├── server.go │ ├── texts.go │ ├── tokens.go │ └── users.go ├── go.mod ├── go.sum ├── internal ├── data │ ├── comments.go │ ├── likes.go │ ├── models.go │ ├── texts.go │ ├── tokens.go │ └── users.go ├── jsonlog │ └── jsonlog.go ├── mailer │ ├── mailer.go │ └── templates │ │ ├── token_password_reset.tmpl │ │ └── user_welcome.tmpl └── validator │ └── validator.go └── migrations ├── 000001_create_texts_table.down.sql ├── 000001_create_texts_table.up.sql ├── 000002_create_users_table.down.sql ├── 000002_create_users_table.up.sql ├── 000003_create_tokens_table.down.sql ├── 000003_create_tokens_table.up.sql ├── 000004_create_likes_table.down.sql ├── 000004_create_likes_table.up.sql ├── 000005_create_comments_table.down.sql ├── 000005_create_comments_table.up.sql ├── 000006_add_userid_to_texts_table.down.sql ├── 000006_add_userid_to_texts_table.up.sql ├── 000007_add_index_to_texts_and_comments_table.down.sql ├── 000007_add_index_to_texts_and_comments_table.up.sql ├── 000008_add_isPrivate_column_to_text_table.down.sql ├── 000008_add_isPrivate_column_to_text_table.up.sql ├── 000009_add_cascade_delete_to_user_relations.down.sql ├── 000009_add_cascade_delete_to_user_relations.up.sql ├── 000010_add_encryption_salt_to_texts_table.down.sql └── 000010_add_encryption_salt_to_texts_table.up.sql /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: theenthusiast 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | bin/ 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | go.work.sum 24 | 25 | # env file 26 | .env 27 | .envrc 28 | 29 | 30 | .DS_STORE 31 | 32 | remote/ 33 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | sahil@theenthusiast.dev. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 The Enthusiast 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # # Load environment variables from .env file 2 | # ifneq (,$(wildcard .env)) 3 | # include .env 4 | # export $(shell sed 's/=.*//' .env) 5 | # endif 6 | 7 | # Load environment variables from .envrc file 8 | ifneq (,$(wildcard .envrc)) 9 | include .envrc 10 | export $(shell sed 's/=.*//' .envrc) 11 | endif 12 | 13 | # ==================================================================================== # 14 | # HELPERS 15 | # ==================================================================================== # 16 | 17 | ## help: print this help message 18 | .PHONY: help 19 | help: 20 | @echo 'Usage:' 21 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 22 | 23 | ## confirm: ask for confirmation before running a command 24 | .PHONY: confirm 25 | confirm: 26 | @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] 27 | 28 | 29 | # ==================================================================================== # 30 | # DEVELOPMENT 31 | # ==================================================================================== # 32 | 33 | ## run/api: run the Go API server 34 | .PHONY: run/api 35 | run/api: 36 | go run ./cmd/api -dsn=${DSN} 37 | 38 | ## db/psql: connect to the database using psql 39 | .PHONY: db/psql 40 | db/psql: 41 | psql ${DSN} 42 | 43 | ## db/migrations/new: create new migration files 44 | .PHONY: db/migrations/new 45 | db/migrations/new: 46 | @echo 'Creating migration files for ${name}...' 47 | migrate create -seq -ext=.sql -dir=./migrations ${name} 48 | 49 | ## db/migrations/up: run up migrations after confirmation 50 | .PHONY: db/migrations/up 51 | db/migrations/up: confirm 52 | @echo 'Running up migrations...' 53 | migrate -path ./migrations -database ${DSN} up 54 | 55 | 56 | # ==================================================================================== # 57 | # QUALITY CONTROL 58 | # ==================================================================================== # 59 | 60 | ## audit: tidy dependencies and format, vet and test all code 61 | .PHONY: audit 62 | audit: 63 | @echo 'Tidying and verifying module dependencies...' 64 | go mod tidy 65 | go mod verify 66 | @echo 'Formatting code...' 67 | go fmt ./... 68 | @echo 'Vetting code...' 69 | go vet ./... 70 | staticcheck ./... 71 | @echo 'Running tests...' 72 | go test -race -vet=off ./... 73 | 74 | 75 | # ==================================================================================== # 76 | # BUILD 77 | # ==================================================================================== # 78 | 79 | current_time = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 80 | git_description = $(shell git describe --always --dirty --tags --long) 81 | 82 | ## build/api: build the cmd/api application 83 | .PHONY: build/api 84 | build/api: 85 | @echo 'Building cmd/api...' 86 | @echo 'Current time: $(current_time)' 87 | @echo 'Git description: $(git_description)' 88 | go build -ldflags="-s -X main.buildTime=$(current_time) -X main.version=$(git_description)" -o=./bin/api ./cmd/api 89 | GOOS=linux GOARCH=amd64 go build -ldflags="-s -X main.buildTime=$(current_time) -X main.version=$(git_description)" -o=./bin/linux_amd64/api ./cmd/api 90 | 91 | 92 | # ==================================================================================== # 93 | # PRODUCTION 94 | # ==================================================================================== # 95 | 96 | production_host_ip = '64.227.180.23' 97 | 98 | ## production/connect: connect to the production server 99 | .PHONY: production/connect 100 | production/connect: 101 | ssh textbin@${production_host_ip} 102 | 103 | ## production/deploy/api: deploy the api to production 104 | .PHONY: production/deploy/api 105 | production/deploy/api: 106 | rsync -P ./bin/linux_amd64/api textbin@${production_host_ip}:~ 107 | rsync -rP --delete ./migrations textbin@${production_host_ip}:~ 108 | rsync -P ./remote/production/api.service textbin@${production_host_ip}:~ 109 | rsync -P ./remote/production/Caddyfile textbin@${production_host_ip}:~ 110 | ssh -t textbin@${production_host_ip} '\ 111 | migrate -path ~/migrations -database $$DSN up \ 112 | && sudo mv ~/api.service /etc/systemd/system/ \ 113 | && sudo systemctl enable api \ 114 | && sudo systemctl restart api \ 115 | && sudo mv ~/Caddyfile /etc/caddy/ \ 116 | && sudo systemctl reload caddy \ 117 | ' 118 | 119 | ## production/configure/caddyfile: configure the production Caddyfile 120 | .PHONY: production/configure/caddyfile 121 | production/configure/caddyfile: 122 | rsync -P ./remote/production/Caddyfile textbin@${production_host_ip}:~ 123 | ssh -t textbin@${production_host_ip} '\ 124 | sudo mv ~/Caddyfile /etc/caddy/ \ 125 | && sudo systemctl reload caddy \ 126 | ' 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TextBin 📝 2 | 3 | ![Go](https://img.shields.io/badge/Go-1.21+-00ADD8?style=for-the-badge&logo=go) 4 | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791?style=for-the-badge&logo=postgresql) 5 | ![Docker](https://img.shields.io/badge/Docker-🐳-2496ED?style=for-the-badge&logo=docker) 6 | 7 | TextBin is a modern, feature-rich pastebin alternative built with Go. It allows users to easily share and manage text snippets with powerful functionality and a clean, intuitive API. 8 | 9 | ## 🌟 Features 10 | 11 | ### Implemented ✅ 12 | 13 | - 🔐 User authentication and authorization 14 | - User registration and login 15 | - JWT-based authentication 16 | - 📝 Text snippet management 17 | - Create, read, update, and delete text snippets 18 | - Support for public and private snippets 19 | - ⏳ Expiration settings for snippets 20 | - 🎨 Syntax highlighting support 21 | - 👍 Like system for snippets 22 | - 💬 Commenting system 23 | - 🔒 CORS support 24 | - 📊 Basic rate limiting 25 | 26 | ### Planned Enhancements 🚀 27 | 28 | - 🔍 Full-text search capabilities 29 | - 📈 Advanced rate limiting and request throttling 30 | - 📨 Email notifications 31 | - 🔗 Sharing via short URLs 32 | - 📱 Mobile-friendly API endpoints 33 | - 🔄 Version history for snippets 34 | - 🏷️ Tagging system for better organization 35 | - 👥 User groups and collaboration features 36 | - 🔐 Two-factor authentication (2FA) 37 | - 📊 User dashboard with usage statistics 38 | - 🌐 Multi-language support 39 | - 🔌 API for third-party integrations 40 | 41 | ## 🛠️ Technology Stack 42 | 43 | - **Backend:** Go 1.21+ 44 | - **Database:** PostgreSQL 15+ 45 | - **Authentication:** JWT 46 | - **API Documentation:** Swagger/OpenAPI (planned) 47 | - **Containerization:** Docker 48 | 49 | ## 🚀 Getting Started 50 | 51 | ### Prerequisites 52 | 53 | - Go 1.21 or later 54 | - PostgreSQL 15 or later 55 | - Docker (optional, for containerized deployment) 56 | 57 | ### Installation 58 | 59 | 1. Clone the repository: 60 | ```bash 61 | git clone https://github.com/your-username/textbin.git 62 | cd textbin 63 | ``` 64 | 2. Set up the PostgreSQL database: 65 | ```bash 66 | psql -U postgres -c "CREATE DATABASE textbin" 67 | ``` 68 | 3. Copy the example environment file and configure the environment variables: 69 | ```bash 70 | DB_DSN=postgres://username:password@localhost/textbin?sslmode=disable 71 | JWT_SECRET=your_jwt_secret_here 72 | SMTP_HOST=smtp.example.com 73 | SMTP_PORT=587 74 | SMTP_USERNAME=your_username 75 | SMTP_PASSWORD=your_password 76 | SMTP_SENDER=TextBin 77 | ``` 78 | 4. Run db migrations: 79 | ```bash 80 | go run ./cmd/migrate 81 | ``` 82 | 5. Start the server: 83 | ```bash 84 | go run ./cmd/api/main.go 85 | ``` 86 | 6. Visit `http://localhost:4000/v1/healthcheck` in your browser to see the API status. 87 | 88 | ## 🤝 Contributing 89 | 90 | We welcome contributions! Please see our Contribution Guidelines for more information on how to get started. 91 | 92 | ## 📄 License 93 | 94 | This project is licensed under the MIT License - see the LICENSE file for details. 95 | 96 | ## 🙏 Acknowledgements 97 | 98 | Go Programming Language 99 | PostgreSQL 100 | JWT-Go 101 | 102 | ## 📞 Support 103 | 104 | If you encounter any issues or have questions, please open an issue on our GitHub repository. 105 | -------------------------------------------------------------------------------- /cmd/api/context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "dev.theenthusiast.text-bin/internal/data" 8 | ) 9 | 10 | type contextKey string 11 | 12 | const userContextKey = contextKey("user") 13 | 14 | func (app *application) contextSetUser(r *http.Request, user *data.User) *http.Request { 15 | ctx := context.WithValue(r.Context(), userContextKey, user) 16 | return r.WithContext(ctx) 17 | } 18 | 19 | func (app *application) contextGetUser(r *http.Request) *data.User { 20 | user, ok := r.Context().Value(userContextKey).(*data.User) 21 | if !ok { 22 | panic("missing user value in request context") 23 | } 24 | return user 25 | } 26 | -------------------------------------------------------------------------------- /cmd/api/errors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // This is a generic error message that will be returned to the client 9 | func (app *application) logError(r *http.Request, err error) { 10 | app.logger.PrintError(err, map[string]string{ 11 | "request_method": r.Method, 12 | "request_url": r.URL.String(), 13 | }) 14 | } 15 | 16 | func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) { 17 | env := envelope{"error": message} 18 | err := app.writeJSON(w, status, env, nil) 19 | if err != nil { 20 | app.logError(r, err) 21 | w.WriteHeader(http.StatusInternalServerError) 22 | } 23 | } 24 | 25 | // This will be used when there's a runtime error in the application. 26 | // It logs the error, then uses the errorResponse() helper to send a 500 Internal Server Error status code and JSON response to the client. 27 | func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { 28 | message := "The server encountered a problem and could not process your request" 29 | app.errorResponse(w, r, http.StatusInternalServerError, message) 30 | } 31 | 32 | // This will be used when there's a not found error in the application. 33 | func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { 34 | message := "The requested resource could not be found" 35 | app.errorResponse(w, r, http.StatusNotFound, message) 36 | } 37 | 38 | // This will be used to send a 405 Method Not Allowed response to the client. 39 | func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { 40 | message := fmt.Sprintf("The %s requested resource does not support the requested HTTP method", r.Method) 41 | app.errorResponse(w, r, http.StatusMethodNotAllowed, message) 42 | } 43 | 44 | func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { 45 | app.errorResponse(w, r, http.StatusBadRequest, err.Error()) 46 | } 47 | 48 | func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { 49 | app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) 50 | } 51 | 52 | func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { 53 | message := "Unable to update the record due to an edit conflict, please try again" 54 | app.errorResponse(w, r, http.StatusConflict, message) 55 | } 56 | 57 | func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { 58 | message := "Invalid authentication credentials" 59 | app.errorResponse(w, r, http.StatusUnauthorized, message) 60 | } 61 | 62 | func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { 63 | w.Header().Set("WWW-Authenticate", "Bearer") 64 | message := "Invalid or missing authentication token" 65 | app.errorResponse(w, r, http.StatusUnauthorized, message) 66 | } 67 | 68 | func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { 69 | message := "You must be authenticated to access this resource" 70 | app.errorResponse(w, r, http.StatusUnauthorized, message) 71 | } 72 | 73 | func (app *application) notPermittedResponse(w http.ResponseWriter, r *http.Request) { 74 | message := "You are not permitted to access this resource" 75 | app.errorResponse(w, r, http.StatusForbidden, message) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/api/healthcheck.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // healthCheckHandler will be used to check the health of the application 8 | func (app *application) healthCheckHandler(w http.ResponseWriter, r *http.Request) { 9 | 10 | env := envelope{ 11 | "status": "available", 12 | "system_info": map[string]string{ 13 | "environment": app.config.env, 14 | "version": version, 15 | }, 16 | } 17 | 18 | err := app.writeJSON(w, http.StatusOK, env, nil) 19 | if err != nil { 20 | app.serverErrorResponse(w, r, err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/api/helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | type envelope map[string]interface{} 19 | 20 | func (app *application) readIDParam(r *http.Request) (string, error) { 21 | params := httprouter.ParamsFromContext(r.Context()) 22 | id := params.ByName("id") 23 | if id == "" { 24 | return "", errors.New("invalid id parameter") 25 | } 26 | return id, nil 27 | } 28 | 29 | func (app *application) readIntParam(r *http.Request, param string) (int64, error) { 30 | params := httprouter.ParamsFromContext(r.Context()) 31 | id := params.ByName(param) 32 | 33 | idInt, err := strconv.ParseInt(id, 10, 64) 34 | if err != nil || idInt < 1 { 35 | return 0, fmt.Errorf("invalid %s parameter", param) 36 | } 37 | 38 | return idInt, nil 39 | } 40 | 41 | func (app *application) readSlugParam(r *http.Request) (string, error) { 42 | params := httprouter.ParamsFromContext(r.Context()) 43 | slug := params.ByName("slug") 44 | if slug == "" { 45 | return "", errors.New("invalid slug parameter") 46 | } 47 | return slug, nil 48 | } 49 | 50 | func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { 51 | // Encode the data to JSON, returning the error if there was one 52 | js, err := json.Marshal(data) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | // append a newline character to the JSON response just to make it easier to read in the terminal 58 | js = append(js, '\n') 59 | 60 | // Now, we can safely set the headers as there will be no errors after this point 61 | for key, value := range headers { 62 | w.Header()[key] = value 63 | } 64 | w.Header().Set("Content-Type", "application/json") 65 | w.WriteHeader(status) 66 | w.Write(js) 67 | return nil 68 | } 69 | 70 | func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error { 71 | maxByes := 1_048_576 72 | // Limiting the size of the request body to 1MB 73 | r.Body = http.MaxBytesReader(w, r.Body, int64(maxByes)) 74 | // initializing a new JSON decoder 75 | dec := json.NewDecoder(r.Body) 76 | // DisallowUnknownFields() method will cause the decoder to return an error if the destination type has a field that can't be set from the JSON 77 | dec.DisallowUnknownFields() 78 | err := dec.Decode(dst) 79 | if err != nil { 80 | // if there's an error in decoding, start the triage process 81 | var syntaxError *json.SyntaxError 82 | var unmarshalTypeError *json.UnmarshalTypeError 83 | var invalidUnmarshalError *json.InvalidUnmarshalError 84 | 85 | switch { 86 | // use the errors.As() function to check whether the error is of the specific type. If it does, then return a plain english error message which indicates the specific location of the problem. 87 | case errors.As(err, &syntaxError): 88 | return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) 89 | case errors.Is(err, io.ErrUnexpectedEOF): 90 | return errors.New("body contains badly-formed JSON") 91 | case errors.As(err, &unmarshalTypeError): 92 | if unmarshalTypeError.Field != "" { 93 | return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) 94 | } 95 | return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) 96 | case errors.Is(err, io.EOF): 97 | return errors.New("body must not be empty") 98 | case strings.HasPrefix(err.Error(), "json: unknown field "): 99 | fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") 100 | return fmt.Errorf("body contains unknown field %s", fieldName) 101 | case err.Error() == "http: request body too large": 102 | return fmt.Errorf("body must not be larger than %d bytes", maxByes) 103 | case errors.As(err, &invalidUnmarshalError): 104 | panic(err) 105 | default: 106 | return err 107 | } 108 | 109 | } 110 | // Call Decode() again, using a pointer to an empty anonymous struct as the 111 | // destination. If the request body only contained a single JSON value this will 112 | // return an io.EOF error. So if we get anything else, we know that there is 113 | // additional data in the request body and we return our own custom error message. 114 | err = dec.Decode(&struct{}{}) 115 | if err != io.EOF { 116 | return errors.New("body must only contain a single JSON value") 117 | } 118 | return nil 119 | } 120 | 121 | // expirationTime calculates the expiration time based on the expiresValue and expiresUnit 122 | func (app *application) expirationTime(expiresValue int, expiresUnit string) (time.Time, error) { 123 | now := time.Now() 124 | 125 | switch expiresUnit { 126 | case "seconds": 127 | return now.Add(time.Duration(expiresValue) * time.Second), nil 128 | case "minutes": 129 | return now.Add(time.Duration(expiresValue) * time.Minute), nil 130 | case "hours": 131 | return now.Add(time.Duration(expiresValue) * time.Hour), nil 132 | case "days": 133 | return now.Add(time.Duration(expiresValue) * time.Hour * 24), nil 134 | case "weeks": 135 | return now.Add(time.Duration(expiresValue) * time.Hour * 24 * 7), nil 136 | case "months": 137 | return now.AddDate(0, expiresValue, 0), nil 138 | case "years": 139 | return now.AddDate(expiresValue, 0, 0), nil 140 | default: 141 | return time.Time{}, fmt.Errorf("invalid expires unit: %s", expiresUnit) 142 | } 143 | } 144 | 145 | // The background() helper accepts an arbitrary function as a parameter. 146 | func (app *application) background(fn func()) { 147 | app.wg.Add(1) 148 | // Launch a background goroutine. 149 | go func() { 150 | app.wg.Done() 151 | // Recover any panic. 152 | defer func() { 153 | if err := recover(); err != nil { 154 | app.logger.PrintError(fmt.Errorf("%s", err), nil) 155 | } 156 | }() 157 | 158 | // Execute the arbitrary function that we passed as the parameter. 159 | fn() 160 | }() 161 | } 162 | 163 | func (app *application) generateSlug(title string, content string) string { 164 | sanitized := strings.ToLower(strings.ReplaceAll(title, " ", "-")) 165 | sanitized = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(sanitized, "") 166 | 167 | hash := sha256.Sum256([]byte(content)) 168 | hashString := fmt.Sprintf("%x", hash)[:8] // Use first 8 characters of the hash 169 | 170 | return fmt.Sprintf("%s-%s", sanitized, hashString) 171 | } 172 | -------------------------------------------------------------------------------- /cmd/api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "expvar" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "runtime" 11 | "sync" 12 | "time" 13 | 14 | "dev.theenthusiast.text-bin/internal/data" 15 | "dev.theenthusiast.text-bin/internal/jsonlog" 16 | "dev.theenthusiast.text-bin/internal/mailer" 17 | _ "github.com/lib/pq" 18 | ) 19 | 20 | var ( 21 | buildTime string 22 | version string 23 | ) 24 | 25 | // Config struct will be used to hold all the configuration settings of the application 26 | type config struct { 27 | port int 28 | env string 29 | db struct { 30 | dsn string 31 | maxOpenConns int 32 | maxIdleConns int 33 | maxIdleTime string 34 | } 35 | smtp struct { 36 | host string 37 | port int 38 | username string 39 | password string 40 | sender string 41 | } 42 | } 43 | 44 | // Application struct will be used to hold all the dependencies of the application 45 | type application struct { 46 | config config 47 | logger *jsonlog.Logger 48 | models data.Models 49 | mailer mailer.Mailer 50 | wg sync.WaitGroup 51 | } 52 | 53 | func main() { 54 | // instance of config struct 55 | var cfg config 56 | 57 | // reading configuration settings from command line flags 58 | flag.IntVar(&cfg.port, "port", 4000, "API server port") 59 | flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)") 60 | flag.StringVar(&cfg.db.dsn, "dsn", os.Getenv("DSN"), "PostgreSQL DSN") 61 | 62 | // Read the connection pool settings from command-line flags into the config struct. 63 | flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections") 64 | flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections") 65 | flag.StringVar(&cfg.db.maxIdleTime, "db-max-idle-time", "15m", "PostgreSQL max connection idle time") 66 | 67 | flag.StringVar(&cfg.smtp.host, "smtp-host", os.Getenv("SMTP_HOST"), "SMTP host") 68 | flag.IntVar(&cfg.smtp.port, "smtp-port", 587, "SMTP port") 69 | flag.StringVar(&cfg.smtp.username, "smtp-username", os.Getenv("SMTP_USERNAME"), "SMTP username") 70 | flag.StringVar(&cfg.smtp.password, "smtp-password", os.Getenv("SMTP_PASSWORD"), "SMTP password") 71 | flag.StringVar(&cfg.smtp.sender, "smtp-sender", "TextBin ", "SMTP sender") 72 | 73 | // Create a new version boolean flag with the default value of false. 74 | displayVersion := flag.Bool("version", false, "Display version and exit") 75 | 76 | flag.Parse() 77 | 78 | // If the version flag value is true, then print out the version number and 79 | // immediately exit. 80 | if *displayVersion { 81 | fmt.Printf("Version:\t%s\n", version) 82 | fmt.Printf("Build time:\t%s\n", buildTime) 83 | os.Exit(0) 84 | } 85 | 86 | // instance of logger 87 | logger := jsonlog.New(os.Stdout, jsonlog.LevelInfo) 88 | 89 | db, err := openDB(cfg) 90 | if err != nil { 91 | logger.PrintFatal(err, nil) 92 | } 93 | 94 | defer db.Close() 95 | 96 | logger.PrintInfo("database connection pool established", nil) 97 | 98 | expvar.NewString("version").Set(version) 99 | 100 | expvar.Publish("goroutines", expvar.Func(func() interface{} { 101 | return runtime.NumGoroutine() 102 | })) 103 | 104 | // Publish the database connection pool statistics. 105 | expvar.Publish("database", expvar.Func(func() interface{} { 106 | return db.Stats() 107 | })) 108 | 109 | // Publish the current Unix timestamp. 110 | expvar.Publish("timestamp", expvar.Func(func() interface{} { 111 | return time.Now().Unix() 112 | })) 113 | 114 | app := &application{ 115 | config: cfg, 116 | logger: logger, 117 | models: data.NewModels(db), 118 | mailer: mailer.New(cfg.smtp.host, cfg.smtp.port, cfg.smtp.username, cfg.smtp.password, cfg.smtp.sender), 119 | } 120 | 121 | err = app.serve() 122 | if err != nil { 123 | app.logger.PrintFatal(err, nil) 124 | } 125 | } 126 | 127 | // The openDB() function returns a sql.DB connection pool. 128 | func openDB(cfg config) (*sql.DB, error) { 129 | // Use sql.Open() to create an empty connection pool, using the DSN from the config 130 | // struct. 131 | db, err := sql.Open("postgres", cfg.db.dsn) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | // Set the maximum number of open (in-use + idle) connections in the pool. 137 | db.SetMaxOpenConns(cfg.db.maxOpenConns) 138 | 139 | // Set the maximum number of idle connections in the pool. 140 | db.SetMaxIdleConns(cfg.db.maxIdleConns) 141 | 142 | // Parse the value of the cfg.db.maxIdleTime string into a time.Duration type. 143 | duration, err := time.ParseDuration(cfg.db.maxIdleTime) 144 | if err != nil { 145 | return nil, err 146 | } 147 | db.SetConnMaxIdleTime(duration) 148 | 149 | // Create a context with a 5-second timeout deadline. 150 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 151 | defer cancel() 152 | // Use PingContext() to establish a new connection to the database, passing in the 153 | // context we created above as a parameter. If the connection couldn't be 154 | // established successfully within the 5 second deadline, then this will return an 155 | // error. 156 | err = db.PingContext(ctx) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | // Return the sql.DB connection pool. 162 | return db, nil 163 | } 164 | -------------------------------------------------------------------------------- /cmd/api/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "expvar" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "dev.theenthusiast.text-bin/internal/data" 11 | "dev.theenthusiast.text-bin/internal/validator" 12 | "github.com/felixge/httpsnoop" 13 | ) 14 | 15 | func (app *application) recoverPanic(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | // Create a deferred function (which will always be run in the event of a panic as Go unwinds the stack). 18 | defer func() { 19 | // Use the builtin recover function to check if there has been a panic. 20 | if err := recover(); err != nil { 21 | // Call the serverErrorResponse helper method to return a 500 Internal Server Error response. 22 | app.serverErrorResponse(w, r, err.(error)) 23 | } 24 | }() 25 | 26 | // Call the ServeHTTP method on the next http.Handler in the chain. 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | 31 | func (app *application) metrics(next http.Handler) http.Handler { 32 | totalRequestsReceived := expvar.NewInt("total_requests_received") 33 | totalResponsesSent := expvar.NewInt("total_responses_sent") 34 | totalProcessingTimeMicroseconds := expvar.NewInt("total_processing_time_μs") 35 | // Declare a new expvar map to hold the count of responses for each HTTP status 36 | // code. 37 | totalResponsesSentByStatus := expvar.NewMap("total_responses_sent_by_status") 38 | 39 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | // Increment the requests received count, like before. 41 | totalRequestsReceived.Add(1) 42 | 43 | // Call the httpsnoop.CaptureMetrics() function, passing in the next handler in 44 | // the chain along with the existing http.ResponseWriter and http.Request. This 45 | // returns the metrics struct that we saw above. 46 | metrics := httpsnoop.CaptureMetrics(next, w, r) 47 | 48 | // Increment the response sent count, like before. 49 | totalResponsesSent.Add(1) 50 | 51 | // Get the request processing time in microseconds from httpsnoop and increment 52 | // the cumulative processing time. 53 | totalProcessingTimeMicroseconds.Add(metrics.Duration.Microseconds()) 54 | 55 | // Use the Add() method to increment the count for the given status code by 1. 56 | // Note that the expvar map is string-keyed, so we need to use the strconv.Itoa() 57 | // function to convert the status code (which is an integer) to a string. 58 | totalResponsesSentByStatus.Add(strconv.Itoa(metrics.Code), 1) 59 | }) 60 | } 61 | 62 | func (app *application) authenticate(next http.Handler) http.Handler { 63 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | w.Header().Add("Vary", "Authorization") 65 | 66 | authorizationHeader := r.Header.Get("Authorization") 67 | 68 | if authorizationHeader == "" { 69 | r = app.contextSetUser(r, data.AnonymousUser) 70 | next.ServeHTTP(w, r) 71 | return 72 | } 73 | 74 | headerParts := strings.Split(authorizationHeader, " ") 75 | if len(headerParts) != 2 || headerParts[0] != "Bearer" { 76 | app.invalidAuthenticationTokenResponse(w, r) 77 | return 78 | } 79 | 80 | token := headerParts[1] 81 | 82 | v := validator.New() 83 | 84 | if data.ValidateTokenPlaintext(v, token); !v.Valid() { 85 | app.invalidAuthenticationTokenResponse(w, r) 86 | return 87 | } 88 | 89 | user, err := app.models.Users.GetForToken(data.ScopeAuthentication, token) 90 | if err != nil { 91 | switch { 92 | case errors.Is(err, data.ErrRecordNotFound): 93 | app.invalidAuthenticationTokenResponse(w, r) 94 | default: 95 | app.serverErrorResponse(w, r, err) 96 | } 97 | return 98 | } 99 | 100 | r = app.contextSetUser(r, user) 101 | 102 | next.ServeHTTP(w, r) 103 | }) 104 | } 105 | 106 | func (app *application) enableCORS(next http.Handler) http.Handler { 107 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 | w.Header().Set("Access-Control-Allow-Origin", "*") 109 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") 110 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 111 | 112 | // Handle preflight request 113 | if r.Method == http.MethodOptions { 114 | w.WriteHeader(http.StatusOK) 115 | return 116 | } 117 | 118 | next.ServeHTTP(w, r) 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /cmd/api/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "expvar" 5 | "net/http" 6 | 7 | "github.com/julienschmidt/httprouter" 8 | ) 9 | 10 | func (app *application) routes() http.Handler { 11 | // initialize a new httprouter instance 12 | router := httprouter.New() 13 | 14 | // Convert the notFoundResponse() helper to a http.Handler using the 15 | // http.HandlerFunc() adapter, and then set it as the custom error handler for 404 16 | // Not Found responses. 17 | router.NotFound = http.HandlerFunc(app.notFoundResponse) 18 | 19 | // Convert the methodNotAllowedResponse() helper to a http.Handler using the http.HandlerFunc() adapter, and then set it as the custom error handler for 405 Method Not Allowed responses. 20 | router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) 21 | 22 | // register the healthcheck handler function with the router 23 | router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthCheckHandler) 24 | 25 | router.HandlerFunc(http.MethodPost, "/v1/texts", app.createTextHandler) 26 | router.HandlerFunc(http.MethodGet, "/v1/texts/:id", app.showTextHandler) 27 | router.HandlerFunc(http.MethodPatch, "/v1/texts/:id", app.updateTextHandler) 28 | router.HandlerFunc(http.MethodDelete, "/v1/texts/:id", app.deleteTextHandler) 29 | 30 | router.HandlerFunc(http.MethodPost, "/v1/users/email", app.getCurrentUser) 31 | router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) 32 | router.HandlerFunc(http.MethodDelete, "/v1/users/:id", app.deleteAccountHandler) 33 | router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) 34 | 35 | router.HandlerFunc(http.MethodPost, "/v1/users/authentication", app.createAuthenticationTokenHandler) 36 | 37 | router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler) 38 | 39 | // Add the POST /v1/tokens/password-reset endpoint. 40 | router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) 41 | 42 | router.HandlerFunc(http.MethodPost, "/v1/texts/:id/like", app.addLikeHandler) 43 | router.HandlerFunc(http.MethodDelete, "/v1/texts/:id/like", app.removeLikeHandler) 44 | router.HandlerFunc(http.MethodPost, "/v1/texts/:id/comments", app.addCommentHandler) 45 | router.HandlerFunc(http.MethodDelete, "/v1/texts/:id/comments/:commentID", app.deleteCommentHandler) 46 | 47 | router.Handler(http.MethodGet, "/debug/vars", expvar.Handler()) 48 | 49 | // return the router 50 | return app.enableCORS((app.metrics(app.recoverPanic(app.authenticate(router))))) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/api/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | func (app *application) serve() error { 15 | srv := &http.Server{ 16 | Addr: fmt.Sprintf(":%d", app.config.port), 17 | Handler: app.routes(), 18 | IdleTimeout: time.Minute, 19 | ReadTimeout: 10 * time.Second, 20 | WriteTimeout: 30 * time.Second, 21 | } 22 | shutdownError := make(chan error) 23 | 24 | go func() { 25 | quit := make(chan os.Signal, 1) 26 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 27 | s := <-quit 28 | app.logger.PrintInfo("shutting down server", map[string]string{ 29 | "signal": s.String(), 30 | }) 31 | 32 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 | defer cancel() 34 | 35 | err := srv.Shutdown(ctx) 36 | if err != nil { 37 | shutdownError <- err 38 | } 39 | 40 | app.logger.PrintInfo("completing background tasks", map[string]string{ 41 | "addr": srv.Addr, 42 | }) 43 | 44 | app.wg.Wait() 45 | shutdownError <- nil 46 | 47 | }() 48 | 49 | app.logger.PrintInfo("Starting server", map[string]string{ 50 | "addr": srv.Addr, 51 | "env": app.config.env, 52 | }) 53 | 54 | err := srv.ListenAndServe() 55 | if !errors.Is(err, http.ErrServerClosed) { 56 | return err 57 | } 58 | 59 | err = <-shutdownError 60 | if err != nil { 61 | return err 62 | } 63 | 64 | app.logger.PrintInfo("stopped server", map[string]string{ 65 | "addr": srv.Addr, 66 | }) 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/api/texts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "dev.theenthusiast.text-bin/internal/data" 10 | "dev.theenthusiast.text-bin/internal/validator" 11 | ) 12 | 13 | // createTextHandler will be used to create a text 14 | func (app *application) createTextHandler(w http.ResponseWriter, r *http.Request) { 15 | var input struct { 16 | Title string `json:"title"` 17 | Content string `json:"content"` 18 | Format string `json:"format"` 19 | ExpiresValue int `json:"expiresValue"` 20 | ExpiresUnit string `json:"expiresUnit"` 21 | IsPrivate bool `json:"is_private"` 22 | EncryptionSalt string `json:"encryptionSalt"` 23 | } 24 | 25 | err := app.readJSON(w, r, &input) 26 | if err != nil { 27 | app.badRequestResponse(w, r, err) 28 | return 29 | } 30 | 31 | var expires time.Time 32 | if input.ExpiresUnit != "" && input.ExpiresValue != 0 { 33 | expires, err = app.expirationTime(input.ExpiresValue, input.ExpiresUnit) 34 | if err != nil { 35 | app.badRequestResponse(w, r, err) 36 | return 37 | } 38 | } 39 | 40 | user := app.contextGetUser(r) 41 | 42 | text := &data.Text{ 43 | Title: input.Title, 44 | Content: input.Content, 45 | Format: input.Format, 46 | Expires: expires, 47 | IsPrivate: input.IsPrivate, 48 | EncryptionSalt: input.EncryptionSalt, 49 | } 50 | if !user.IsAnonymous() { 51 | text.UserID = &user.ID 52 | } 53 | 54 | slug, err := app.models.Texts.GenerateUniqueSlug(text.Title) 55 | if err != nil { 56 | app.serverErrorResponse(w, r, err) 57 | return 58 | } 59 | text.Slug = slug 60 | 61 | v := validator.New() 62 | if data.ValidateText(v, text); !v.Valid() { 63 | app.failedValidationResponse(w, r, v.Errors) 64 | return 65 | } 66 | 67 | err = app.models.Texts.Insert(text) 68 | if err != nil { 69 | app.serverErrorResponse(w, r, err) 70 | return 71 | } 72 | 73 | headers := make(http.Header) 74 | headers.Set("Location", fmt.Sprintf("/v1/texts/%s", text.Slug)) 75 | 76 | err = app.writeJSON(w, http.StatusCreated, envelope{"text": text}, headers) 77 | if err != nil { 78 | app.serverErrorResponse(w, r, err) 79 | } 80 | } 81 | 82 | // showTextHandler will be used to show a text 83 | func (app *application) showTextHandler(w http.ResponseWriter, r *http.Request) { 84 | slug, err := app.readIDParam(r) 85 | if err != nil { 86 | app.notFoundResponse(w, r) 87 | return 88 | } 89 | 90 | user := app.contextGetUser(r) 91 | var userID *int64 92 | if !user.IsAnonymous() { 93 | userID = &user.ID 94 | } 95 | 96 | text, err := app.models.Texts.Get(slug, userID) 97 | if err != nil { 98 | switch { 99 | case errors.Is(err, data.ErrRecordNotFound): 100 | app.notFoundResponse(w, r) 101 | default: 102 | app.serverErrorResponse(w, r, err) 103 | } 104 | return 105 | } 106 | 107 | err = app.writeJSON(w, http.StatusOK, envelope{"text": text}, nil) 108 | if err != nil { 109 | app.serverErrorResponse(w, r, err) 110 | } 111 | } 112 | 113 | func (app *application) updateTextHandler(w http.ResponseWriter, r *http.Request) { 114 | slug, err := app.readIDParam(r) 115 | if err != nil { 116 | app.notFoundResponse(w, r) 117 | return 118 | } 119 | 120 | user := app.contextGetUser(r) 121 | if user.IsAnonymous() { 122 | app.authenticationRequiredResponse(w, r) 123 | return 124 | } 125 | 126 | text, err := app.models.Texts.Get(slug, &user.ID) 127 | if err != nil { 128 | switch { 129 | case errors.Is(err, data.ErrRecordNotFound): 130 | app.notFoundResponse(w, r) 131 | default: 132 | app.serverErrorResponse(w, r, err) 133 | } 134 | return 135 | } 136 | 137 | var input struct { 138 | Title *string `json:"title"` 139 | Content *string `json:"content"` 140 | Format *string `json:"format"` 141 | ExpiresUnit *string `json:"expiresUnit"` 142 | ExpiresValue *int `json:"expiresValue"` 143 | IsPrivate *bool `json:"is_private"` 144 | } 145 | 146 | err = app.readJSON(w, r, &input) 147 | if err != nil { 148 | app.badRequestResponse(w, r, err) 149 | return 150 | } 151 | 152 | if input.Title != nil { 153 | text.Title = *input.Title 154 | } 155 | if input.Content != nil { 156 | text.Content = *input.Content 157 | } 158 | if input.Format != nil { 159 | text.Format = *input.Format 160 | } 161 | if input.ExpiresUnit != nil && input.ExpiresValue != nil { 162 | text.Expires, err = app.expirationTime(*input.ExpiresValue, *input.ExpiresUnit) 163 | if err != nil { 164 | app.badRequestResponse(w, r, err) 165 | return 166 | } 167 | } 168 | if input.IsPrivate != nil { 169 | text.IsPrivate = *input.IsPrivate 170 | } 171 | 172 | v := validator.New() 173 | if data.ValidateText(v, text); !v.Valid() { 174 | app.failedValidationResponse(w, r, v.Errors) 175 | return 176 | } 177 | 178 | err = app.models.Texts.Update(text, user.ID) 179 | if err != nil { 180 | switch { 181 | case errors.Is(err, data.ErrEditConflict): 182 | app.editConflictResponse(w, r) 183 | default: 184 | app.serverErrorResponse(w, r, err) 185 | } 186 | return 187 | } 188 | 189 | err = app.writeJSON(w, http.StatusOK, envelope{"text": text}, nil) 190 | if err != nil { 191 | app.serverErrorResponse(w, r, err) 192 | } 193 | } 194 | 195 | func (app *application) deleteTextHandler(w http.ResponseWriter, r *http.Request) { 196 | slug, err := app.readIDParam(r) 197 | if err != nil { 198 | app.notFoundResponse(w, r) 199 | return 200 | } 201 | 202 | user := app.contextGetUser(r) 203 | if user.IsAnonymous() { 204 | app.authenticationRequiredResponse(w, r) 205 | return 206 | } 207 | 208 | err = app.models.Texts.Delete(slug, user.ID) 209 | if err != nil { 210 | switch { 211 | case errors.Is(err, data.ErrRecordNotFound): 212 | app.notFoundResponse(w, r) 213 | default: 214 | app.serverErrorResponse(w, r, err) 215 | } 216 | return 217 | } 218 | 219 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "text successfully deleted"}, nil) 220 | if err != nil { 221 | app.serverErrorResponse(w, r, err) 222 | } 223 | } 224 | 225 | func (app *application) addLikeHandler(w http.ResponseWriter, r *http.Request) { 226 | user := app.contextGetUser(r) 227 | if user.IsAnonymous() { 228 | app.authenticationRequiredResponse(w, r) 229 | return 230 | } 231 | 232 | textID, err := app.readIntParam(r, "id") 233 | if err != nil { 234 | app.notFoundResponse(w, r) 235 | return 236 | } 237 | 238 | err = app.models.Likes.AddLike(user.ID, textID) 239 | if err != nil { 240 | app.serverErrorResponse(w, r, err) 241 | return 242 | } 243 | 244 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "Like added successfully"}, nil) 245 | if err != nil { 246 | app.serverErrorResponse(w, r, err) 247 | } 248 | } 249 | 250 | func (app *application) removeLikeHandler(w http.ResponseWriter, r *http.Request) { 251 | user := app.contextGetUser(r) 252 | if user.IsAnonymous() { 253 | app.authenticationRequiredResponse(w, r) 254 | return 255 | } 256 | 257 | textID, err := app.readIntParam(r, "id") 258 | if err != nil { 259 | app.notFoundResponse(w, r) 260 | return 261 | } 262 | 263 | err = app.models.Likes.RemoveLike(user.ID, textID) 264 | if err != nil { 265 | app.serverErrorResponse(w, r, err) 266 | return 267 | } 268 | 269 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "Like removed successfully"}, nil) 270 | if err != nil { 271 | app.serverErrorResponse(w, r, err) 272 | } 273 | } 274 | 275 | func (app *application) addCommentHandler(w http.ResponseWriter, r *http.Request) { 276 | user := app.contextGetUser(r) 277 | if user.IsAnonymous() { 278 | app.authenticationRequiredResponse(w, r) 279 | return 280 | } 281 | 282 | textID, err := app.readIntParam(r, "id") 283 | if err != nil { 284 | app.notFoundResponse(w, r) 285 | return 286 | } 287 | 288 | var input struct { 289 | Content string `json:"content"` 290 | } 291 | 292 | err = app.readJSON(w, r, &input) 293 | if err != nil { 294 | app.badRequestResponse(w, r, err) 295 | return 296 | } 297 | 298 | comment := &data.Comment{ 299 | UserID: user.ID, 300 | TextID: textID, 301 | Content: input.Content, 302 | } 303 | 304 | err = app.models.Comments.AddComment(comment) 305 | if err != nil { 306 | app.serverErrorResponse(w, r, err) 307 | return 308 | } 309 | 310 | err = app.writeJSON(w, http.StatusCreated, envelope{"comment": comment}, nil) 311 | if err != nil { 312 | app.serverErrorResponse(w, r, err) 313 | } 314 | } 315 | 316 | func (app *application) deleteCommentHandler(w http.ResponseWriter, r *http.Request) { 317 | user := app.contextGetUser(r) 318 | if user.IsAnonymous() { 319 | app.authenticationRequiredResponse(w, r) 320 | return 321 | } 322 | 323 | commentID, err := app.readIntParam(r, "commentID") 324 | if err != nil { 325 | app.notFoundResponse(w, r) 326 | return 327 | } 328 | 329 | err = app.models.Comments.DeleteComment(commentID, user.ID) 330 | if err != nil { 331 | switch { 332 | case errors.Is(err, data.ErrRecordNotFound): 333 | app.notFoundResponse(w, r) 334 | default: 335 | app.serverErrorResponse(w, r, err) 336 | } 337 | return 338 | } 339 | 340 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "Comment deleted successfully"}, nil) 341 | if err != nil { 342 | app.serverErrorResponse(w, r, err) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /cmd/api/tokens.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | 8 | "dev.theenthusiast.text-bin/internal/data" 9 | "dev.theenthusiast.text-bin/internal/validator" 10 | ) 11 | 12 | func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { 13 | var input struct { 14 | Email string `json:"email"` 15 | Password string `json:"password"` 16 | } 17 | err := app.readJSON(w, r, &input) 18 | if err != nil { 19 | app.badRequestResponse(w, r, err) 20 | return 21 | } 22 | 23 | v := validator.New() 24 | 25 | data.ValidateEmail(v, input.Email) 26 | data.ValidatePasswordPlaintext(v, input.Password) 27 | 28 | if !v.Valid() { 29 | app.failedValidationResponse(w, r, v.Errors) 30 | return 31 | } 32 | 33 | user, err := app.models.Users.GetByEmail(input.Email) 34 | if err != nil { 35 | switch { 36 | case errors.Is(err, data.ErrRecordNotFound): 37 | app.invalidCredentialsResponse(w, r) 38 | default: 39 | app.serverErrorResponse(w, r, err) 40 | } 41 | return 42 | } 43 | 44 | match, err := user.Password.Matches(input.Password) 45 | if err != nil { 46 | app.serverErrorResponse(w, r, err) 47 | return 48 | } 49 | 50 | if !match { 51 | app.invalidCredentialsResponse(w, r) 52 | return 53 | } 54 | 55 | token, err := app.models.Tokens.New(user.ID, 24*time.Hour, data.ScopeAuthentication) 56 | if err != nil { 57 | app.serverErrorResponse(w, r, err) 58 | return 59 | } 60 | 61 | err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil) 62 | if err != nil { 63 | app.serverErrorResponse(w, r, err) 64 | } 65 | } 66 | 67 | func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) { 68 | var input struct { 69 | Email string `json:"email"` 70 | } 71 | err := app.readJSON(w, r, &input) 72 | if err != nil { 73 | app.badRequestResponse(w, r, err) 74 | return 75 | } 76 | 77 | v := validator.New() 78 | data.ValidateEmail(v, input.Email) 79 | 80 | if !v.Valid() { 81 | app.failedValidationResponse(w, r, v.Errors) 82 | return 83 | } 84 | 85 | user, err := app.models.Users.GetByEmail(input.Email) 86 | if err != nil { 87 | switch { 88 | case errors.Is(err, data.ErrRecordNotFound): 89 | v.AddError("email", "no matching email address found") 90 | app.failedValidationResponse(w, r, v.Errors) 91 | default: 92 | app.serverErrorResponse(w, r, err) 93 | } 94 | return 95 | } 96 | 97 | token, err := app.models.Tokens.New(user.ID, 1*time.Hour, data.ScopePasswordReset) 98 | if err != nil { 99 | app.serverErrorResponse(w, r, err) 100 | return 101 | } 102 | 103 | app.background(func() { 104 | data := map[string]interface{}{ 105 | "passwordResetToken": token.Plaintext, 106 | } 107 | err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data) 108 | if err != nil { 109 | app.logger.PrintError(err, nil) 110 | } 111 | }) 112 | 113 | env := envelope{"message": "password reset instructions have been sent to your email address"} 114 | 115 | err = app.writeJSON(w, http.StatusCreated, env, nil) 116 | if err != nil { 117 | app.serverErrorResponse(w, r, err) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /cmd/api/users.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "time" 7 | 8 | "dev.theenthusiast.text-bin/internal/data" 9 | "dev.theenthusiast.text-bin/internal/validator" 10 | ) 11 | 12 | func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { 13 | var input struct { 14 | Name string `json:"name"` 15 | Email string `json:"email"` 16 | Password string `json:"password"` 17 | } 18 | 19 | err := app.readJSON(w, r, &input) 20 | if err != nil { 21 | app.badRequestResponse(w, r, err) 22 | return 23 | } 24 | 25 | user := &data.User{ 26 | Name: input.Name, 27 | Email: input.Email, 28 | Activated: false, 29 | } 30 | 31 | err = user.Password.Set(input.Password) 32 | if err != nil { 33 | app.serverErrorResponse(w, r, err) 34 | return 35 | } 36 | 37 | v := validator.New() 38 | if data.ValidateUser(v, user); !v.Valid() { 39 | app.failedValidationResponse(w, r, v.Errors) 40 | return 41 | } 42 | 43 | err = app.models.Users.Insert(user) 44 | if err != nil { 45 | switch { 46 | case errors.Is(err, data.ErrDuplicateEmail): 47 | v.AddError("email", "address is already in use") 48 | app.failedValidationResponse(w, r, v.Errors) 49 | default: 50 | app.serverErrorResponse(w, r, err) 51 | } 52 | return 53 | } 54 | 55 | // Launch a goroutine which runs an anonymous function that sends the welcome email. 56 | // go func() { 57 | // defer func() { 58 | // if err := recover(); err != nil { 59 | // app.logger.PrintError(fmt.Errorf("%s", err), nil) 60 | // } 61 | // }() 62 | 63 | // }() 64 | // 65 | 66 | token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation) 67 | if err != nil { 68 | app.serverErrorResponse(w, r, err) 69 | return 70 | } 71 | 72 | app.background(func() { 73 | 74 | data := map[string]interface{}{ 75 | "activationToken": token.Plaintext, 76 | "userID": user.ID, 77 | } 78 | 79 | err = app.mailer.Send(user.Email, "user_welcome.tmpl", data) 80 | if err != nil { 81 | // Importantly, if there is an error sending the email then we use the 82 | // app.logger.PrintError() helper to manage it, instead of the 83 | // app.serverErrorResponse() helper like before. 84 | app.logger.PrintError(err, nil) 85 | } 86 | }) 87 | 88 | err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil) 89 | if err != nil { 90 | app.serverErrorResponse(w, r, err) 91 | } 92 | } 93 | 94 | func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) { 95 | var input struct { 96 | TokenPlaintext string `json:"token"` 97 | } 98 | err := app.readJSON(w, r, &input) 99 | if err != nil { 100 | app.badRequestResponse(w, r, err) 101 | return 102 | } 103 | v := validator.New() 104 | if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() { 105 | app.failedValidationResponse(w, r, v.Errors) 106 | return 107 | } 108 | user, err := app.models.Users.GetForToken(data.ScopeActivation, input.TokenPlaintext) 109 | if err != nil { 110 | switch { 111 | case errors.Is(err, data.ErrRecordNotFound): 112 | v.AddError("token", "invalid or expired activation token") 113 | default: 114 | app.serverErrorResponse(w, r, err) 115 | } 116 | return 117 | } 118 | user.Activated = true 119 | 120 | err = app.models.Users.Update(user) 121 | if err != nil { 122 | switch { 123 | case errors.Is(err, data.ErrEditConflict): 124 | app.editConflictResponse(w, r) 125 | default: 126 | app.serverErrorResponse(w, r, err) 127 | } 128 | return 129 | } 130 | err = app.models.Tokens.DeleteAllForUser(data.ScopeActivation, user.ID) 131 | if err != nil { 132 | app.serverErrorResponse(w, r, err) 133 | return 134 | } 135 | err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) 136 | if err != nil { 137 | app.serverErrorResponse(w, r, err) 138 | } 139 | } 140 | 141 | func (app *application) getCurrentUser(w http.ResponseWriter, r *http.Request) { 142 | var input struct { 143 | Email string `json:"email"` 144 | } 145 | err := app.readJSON(w, r, &input) 146 | if err != nil { 147 | app.badRequestResponse(w, r, err) 148 | return 149 | } 150 | 151 | user, err := app.models.Users.GetByEmail(input.Email) 152 | if err != nil { 153 | switch { 154 | case errors.Is(err, data.ErrRecordNotFound): 155 | app.notFoundResponse(w, r) 156 | default: 157 | app.serverErrorResponse(w, r, err) 158 | } 159 | return 160 | } 161 | 162 | err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) 163 | if err != nil { 164 | app.serverErrorResponse(w, r, err) 165 | } 166 | } 167 | 168 | func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) { 169 | var input struct { 170 | Password string `json:"password"` 171 | TokenPlaintext string `json:"token"` 172 | } 173 | err := app.readJSON(w, r, &input) 174 | if err != nil { 175 | app.badRequestResponse(w, r, err) 176 | return 177 | } 178 | 179 | v := validator.New() 180 | data.ValidatePasswordPlaintext(v, input.Password) 181 | data.ValidateTokenPlaintext(v, input.TokenPlaintext) 182 | if !v.Valid() { 183 | app.failedValidationResponse(w, r, v.Errors) 184 | return 185 | } 186 | 187 | user, err := app.models.Users.GetForToken(data.ScopePasswordReset, input.TokenPlaintext) 188 | if err != nil { 189 | switch { 190 | case errors.Is(err, data.ErrRecordNotFound): 191 | v.AddError("token", "invalid or expired password reset token") 192 | app.failedValidationResponse(w, r, v.Errors) 193 | default: 194 | app.serverErrorResponse(w, r, err) 195 | } 196 | return 197 | } 198 | 199 | err = user.Password.Set(input.Password) 200 | if err != nil { 201 | app.serverErrorResponse(w, r, err) 202 | return 203 | } 204 | 205 | err = app.models.Users.Update(user) 206 | if err != nil { 207 | switch { 208 | case errors.Is(err, data.ErrEditConflict): 209 | app.editConflictResponse(w, r) 210 | default: 211 | app.serverErrorResponse(w, r, err) 212 | } 213 | return 214 | } 215 | 216 | err = app.models.Tokens.DeleteAllForUser(data.ScopePasswordReset, user.ID) 217 | if err != nil { 218 | app.serverErrorResponse(w, r, err) 219 | return 220 | } 221 | 222 | env := envelope{"message": "Your password was successfully reset."} 223 | err = app.writeJSON(w, http.StatusOK, env, nil) 224 | if err != nil { 225 | app.serverErrorResponse(w, r, err) 226 | } 227 | } 228 | 229 | func (app *application) deleteAccountHandler(w http.ResponseWriter, r *http.Request) { 230 | // Get the authenticated user from the request context 231 | user := app.contextGetUser(r) 232 | 233 | // Check if the user is authenticated 234 | if user.IsAnonymous() { 235 | app.authenticationRequiredResponse(w, r) 236 | return 237 | } 238 | 239 | // Get the user ID from the request parameters 240 | userIDToDelete, err := app.readIntParam(r, "id") 241 | if err != nil { 242 | app.badRequestResponse(w, r, err) 243 | return 244 | } 245 | 246 | // Check if the authenticated user is trying to delete their own account 247 | if user.ID != userIDToDelete { 248 | app.notPermittedResponse(w, r) 249 | return 250 | } 251 | 252 | // Proceed with account deletion 253 | err = app.models.Users.DeleteUser(user.ID) 254 | if err != nil { 255 | switch { 256 | case errors.Is(err, data.ErrRecordNotFound): 257 | app.notFoundResponse(w, r) 258 | default: 259 | app.serverErrorResponse(w, r, err) 260 | } 261 | return 262 | } 263 | 264 | // Clear the authentication token cookie 265 | http.SetCookie(w, &http.Cookie{ 266 | Name: "token", 267 | Value: "", 268 | Path: "/", 269 | MaxAge: -1, 270 | HttpOnly: true, 271 | }) 272 | 273 | err = app.writeJSON(w, http.StatusOK, envelope{"message": "Your account has been successfully deleted"}, nil) 274 | if err != nil { 275 | app.serverErrorResponse(w, r, err) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module dev.theenthusiast.text-bin 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/felixge/httpsnoop v1.0.1 7 | github.com/joho/godotenv v1.5.1 8 | github.com/julienschmidt/httprouter v1.3.0 9 | github.com/lib/pq v1.10.0 10 | ) 11 | 12 | require ( 13 | github.com/go-mail/mail/v2 v2.3.0 // indirect 14 | golang.org/x/crypto v0.25.0 // indirect 15 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect 16 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= 2 | github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 3 | github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw= 4 | github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go= 5 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 7 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 8 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 9 | github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= 10 | github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 11 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 12 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 13 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= 14 | golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= 15 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 16 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 17 | -------------------------------------------------------------------------------- /internal/data/comments.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | ) 8 | 9 | type Comment struct { 10 | ID int64 `json:"id"` 11 | UserID int64 `json:"user_id"` 12 | TextID int64 `json:"text_id"` 13 | Content string `json:"content"` 14 | CreatedAt time.Time `json:"created_at"` 15 | UpdatedAt time.Time `json:"updated_at"` 16 | } 17 | 18 | type CommentModel struct { 19 | DB *sql.DB 20 | } 21 | 22 | func (m CommentModel) AddComment(comment *Comment) error { 23 | query := ` 24 | INSERT INTO comments (user_id, text_id, content) 25 | VALUES ($1, $2, $3) 26 | RETURNING id, created_at, updated_at` 27 | 28 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 29 | defer cancel() 30 | 31 | err := m.DB.QueryRowContext(ctx, query, comment.UserID, comment.TextID, comment.Content).Scan(&comment.ID, &comment.CreatedAt, &comment.UpdatedAt) 32 | return err 33 | } 34 | 35 | func (m CommentModel) DeleteComment(commentID, userID int64) error { 36 | query := ` 37 | DELETE FROM comments 38 | WHERE id = $1 AND user_id = $2` 39 | 40 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 41 | defer cancel() 42 | 43 | result, err := m.DB.ExecContext(ctx, query, commentID, userID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | rowsAffected, err := result.RowsAffected() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | if rowsAffected == 0 { 54 | return ErrRecordNotFound 55 | } 56 | 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/data/likes.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | ) 8 | 9 | type Like struct { 10 | ID int64 `json:"id"` 11 | UserID int64 `json:"user_id"` 12 | TextID int64 `json:"text_id"` 13 | CreatedAt time.Time `json:"created_at"` 14 | } 15 | 16 | type LikeModel struct { 17 | DB *sql.DB 18 | } 19 | 20 | func (m LikeModel) AddLike(userID, textID int64) error { 21 | query := ` 22 | INSERT INTO likes (user_id, text_id) 23 | VALUES ($1, $2) 24 | ON CONFLICT (user_id, text_id) DO NOTHING` 25 | 26 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 27 | defer cancel() 28 | 29 | _, err := m.DB.ExecContext(ctx, query, userID, textID) 30 | return err 31 | } 32 | 33 | func (m LikeModel) RemoveLike(userID, textID int64) error { 34 | query := ` 35 | DELETE FROM likes 36 | WHERE user_id = $1 AND text_id = $2` 37 | 38 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 39 | defer cancel() 40 | 41 | _, err := m.DB.ExecContext(ctx, query, userID, textID) 42 | return err 43 | } 44 | -------------------------------------------------------------------------------- /internal/data/models.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | ) 7 | 8 | // Define a custom error type for when an expected record is not found in the database. 9 | var ( 10 | ErrRecordNotFound = errors.New("record not found") 11 | ErrEditConflict = errors.New("edit conflict") 12 | ) 13 | 14 | // Define a Models type which wraps the MovieModel. 15 | type Models struct { 16 | Texts TextModel 17 | Users UserModel 18 | Tokens TokenModel 19 | Comments CommentModel 20 | Likes LikeModel 21 | } 22 | 23 | // Define a NewModels() function which initializes the MovieModel and stores it in the Models type. 24 | func NewModels(db *sql.DB) Models { 25 | return Models{ 26 | Texts: TextModel{DB: db}, 27 | Users: UserModel{DB: db}, 28 | Tokens: TokenModel{DB: db}, 29 | Comments: CommentModel{DB: db}, 30 | Likes: LikeModel{DB: db}, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/data/texts.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "dev.theenthusiast.text-bin/internal/validator" 13 | "golang.org/x/exp/rand" 14 | ) 15 | 16 | // Its important in Go to keep the Fields of a struct in Capotal letter to make it public 17 | // Any field that starts with a lowercase letter is private to the package and aren't exported and won't be included when encoding a struct to JSON 18 | type Text struct { 19 | ID int64 `json:"id"` 20 | CreatedAt time.Time `json:"-"` 21 | Title string `json:"title"` 22 | Content string `json:"content"` 23 | Format string `json:"format"` 24 | Expires time.Time `json:"expires"` 25 | Slug string `json:"slug"` 26 | IsPrivate bool `json:"is_private"` 27 | UserID *int64 `json:"user_id,omitempty"` 28 | LikesCount int `json:"likes_count"` 29 | Comments []Comment `json:"comments,omitempty"` 30 | EncryptionSalt string `json:"encryption_salt"` 31 | Version int32 `json:"-"` 32 | } 33 | 34 | // ValidateText will be used to validate the input data for the Text struct 35 | func ValidateText(v *validator.Validator, text *Text) { 36 | v.Check(text.Title != "", "title", "must be provided") 37 | v.Check(len(text.Title) <= 100, "title", "must not be more than 100 bytes long") 38 | v.Check(text.Content != "", "content", "must be provided") 39 | v.Check(len(text.Content) <= 1000000, "content", "must not be more than 1000000 bytes long") 40 | v.Check(text.Format != "", "format", "must be provided") 41 | v.Check(text.Expires.After(time.Now()), "expires", "must be greater than the current time") 42 | v.Check(text.UserID != nil || !text.IsPrivate, "is_private", "anonymous users cannot create private texts") 43 | 44 | } 45 | 46 | // GenerateRandomCode generates a random string of specified length 47 | // func GenerateRandomCode(n int) (string, error) { 48 | // b := make([]byte, n) 49 | // _, err := rand.Read(b) 50 | // if err != nil { 51 | // return "", err 52 | // } 53 | // return base64.URLEncoding.EncodeToString(b), nil 54 | // } 55 | 56 | // Define a MovieModel struct type which wraps a sql.DB connection pool. 57 | type TextModel struct { 58 | DB *sql.DB 59 | } 60 | 61 | // Insert will add a new record to the texts table 62 | func (m TextModel) Insert(text *Text) error { 63 | query := ` 64 | INSERT INTO texts (title, content, format, expires, slug, user_id, is_private, encryption_salt) 65 | VALUES($1, $2, $3, $4, $5, $6, $7, $8) 66 | RETURNING id, created_at, version 67 | ` 68 | args := []interface{}{ 69 | text.Title, 70 | text.Content, 71 | text.Format, 72 | text.Expires, 73 | text.Slug, 74 | text.UserID, 75 | text.IsPrivate, 76 | text.EncryptionSalt, 77 | } 78 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 79 | defer cancel() 80 | 81 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&text.ID, &text.CreatedAt, &text.Version) 82 | if err != nil { 83 | return fmt.Errorf("failed to insert text: %v", err) 84 | } 85 | return nil 86 | } 87 | 88 | var randomSource rand.Source 89 | var randomGenerator *rand.Rand 90 | 91 | func init() { 92 | // Convert int64 to uint64 without losing information 93 | seed := uint64(time.Now().UnixNano()) 94 | source := rand.NewSource(seed) 95 | randomGenerator = rand.New(source) 96 | } 97 | 98 | func (m TextModel) GenerateUniqueSlug(title string) (string, error) { 99 | baseSlug := generateBaseSlug(title) 100 | 101 | for attempts := 0; attempts < 10; attempts++ { 102 | slug := baseSlug 103 | if attempts > 0 { 104 | // Add a short random string instead of a number 105 | randomStr := generateRandomString(3) 106 | slug = fmt.Sprintf("%s-%s", baseSlug, randomStr) 107 | } 108 | 109 | exists, err := m.slugExists(slug) 110 | if err != nil { 111 | return "", err 112 | } 113 | if !exists { 114 | return slug, nil 115 | } 116 | } 117 | 118 | // If all attempts fail, generate a completely random slug 119 | return generateRandomString(8), nil 120 | } 121 | 122 | func generateBaseSlug(title string) string { 123 | title = strings.ToLower(title) 124 | reg := regexp.MustCompile("[^a-z0-9]+") 125 | title = reg.ReplaceAllString(title, " ") 126 | words := strings.Fields(title) 127 | if len(words) > 3 { 128 | words = words[:3] 129 | } 130 | slug := strings.Join(words, "-") 131 | if len(slug) > 20 { 132 | slug = slug[:20] 133 | } 134 | return strings.TrimRight(slug, "-") 135 | } 136 | 137 | func generateRandomString(length int) string { 138 | const charset = "abcdefghijklmnopqrstuvwxyz0123456789" 139 | b := make([]byte, length) 140 | for i := range b { 141 | b[i] = charset[randomGenerator.Intn(len(charset))] 142 | } 143 | return string(b) 144 | } 145 | func (m TextModel) slugExists(slug string) (bool, error) { 146 | var exists bool 147 | query := "SELECT EXISTS(SELECT 1 FROM texts WHERE slug = $1)" 148 | err := m.DB.QueryRow(query, slug).Scan(&exists) 149 | return exists, err 150 | } 151 | 152 | // Get will return a specific record from the texts table based on the id 153 | func (m TextModel) Get(slug string, userID *int64) (*Text, error) { 154 | query := ` 155 | SELECT id, created_at, title, content, format, expires, slug, version, user_id, is_private, encryption_salt, 156 | (SELECT COUNT(*) FROM likes WHERE text_id = texts.id) as likes_count 157 | FROM texts 158 | WHERE slug = $1` 159 | 160 | var text Text 161 | 162 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 163 | defer cancel() 164 | 165 | err := m.DB.QueryRowContext(ctx, query, slug).Scan( 166 | &text.ID, &text.CreatedAt, &text.Title, &text.Content, &text.Format, 167 | &text.Expires, &text.Slug, &text.Version, &text.UserID, &text.IsPrivate, 168 | &text.EncryptionSalt, &text.LikesCount) 169 | 170 | if err != nil { 171 | switch { 172 | case errors.Is(err, sql.ErrNoRows): 173 | return nil, ErrRecordNotFound 174 | default: 175 | return nil, err 176 | } 177 | } 178 | 179 | // Check if the text is private and the user is not the owner 180 | if text.IsPrivate && (userID == nil || *userID != *text.UserID) { 181 | return nil, ErrRecordNotFound 182 | } 183 | 184 | // Fetch comments 185 | commentsQuery := ` 186 | SELECT id, user_id, content, created_at, updated_at 187 | FROM comments 188 | WHERE text_id = $1 189 | ORDER BY created_at DESC` 190 | 191 | rows, err := m.DB.QueryContext(ctx, commentsQuery, text.ID) 192 | if err != nil { 193 | return nil, err 194 | } 195 | defer rows.Close() 196 | 197 | for rows.Next() { 198 | var comment Comment 199 | err := rows.Scan(&comment.ID, &comment.UserID, &comment.Content, &comment.CreatedAt, &comment.UpdatedAt) 200 | if err != nil { 201 | return nil, err 202 | } 203 | text.Comments = append(text.Comments, comment) 204 | } 205 | 206 | if err = rows.Err(); err != nil { 207 | return nil, err 208 | } 209 | 210 | return &text, nil 211 | } 212 | 213 | // Update will update a specific record in the texts table based on the id 214 | // Update will update a specific record in the texts table based on the id 215 | func (m TextModel) Update(text *Text, userID int64) error { 216 | query := ` 217 | UPDATE texts 218 | SET title = $1, content = $2, format = $3, expires = $4, is_private = $5, encryption_salt = $6, version = version + 1 219 | WHERE slug = $7 AND version = $8 AND (user_id = $9 OR user_id IS NULL) 220 | RETURNING version 221 | ` 222 | args := []interface{}{ 223 | text.Title, 224 | text.Content, 225 | text.Format, 226 | text.Expires, 227 | text.IsPrivate, 228 | text.EncryptionSalt, 229 | text.Slug, 230 | text.Version, 231 | userID, 232 | } 233 | 234 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 235 | defer cancel() 236 | 237 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&text.Version) 238 | if err != nil { 239 | switch { 240 | case errors.Is(err, sql.ErrNoRows): 241 | return ErrEditConflict 242 | default: 243 | return err 244 | } 245 | } 246 | return nil 247 | } 248 | 249 | // Delete will remove a specific record from the texts table based on the id 250 | // Delete will remove a specific record from the texts table based on the id 251 | func (m TextModel) Delete(slug string, userID int64) error { 252 | if slug == "" { 253 | return ErrRecordNotFound 254 | } 255 | query := ` 256 | DELETE FROM texts 257 | WHERE slug = $1 AND (user_id = $2 OR user_id IS NULL) 258 | ` 259 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) 260 | defer cancel() 261 | 262 | result, err := m.DB.ExecContext(ctx, query, slug, userID) 263 | if err != nil { 264 | return err 265 | } 266 | rowsAffected, err := result.RowsAffected() 267 | if err != nil { 268 | return err 269 | } 270 | if rowsAffected == 0 { 271 | return ErrRecordNotFound 272 | } 273 | return nil 274 | } 275 | -------------------------------------------------------------------------------- /internal/data/tokens.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "database/sql" 8 | "encoding/base32" 9 | "time" 10 | 11 | "dev.theenthusiast.text-bin/internal/validator" 12 | ) 13 | 14 | const ( 15 | ScopeActivation = "activation" 16 | ScopeAuthentication = "authentication" 17 | ScopePasswordReset = "password-reset" 18 | ) 19 | 20 | type Token struct { 21 | Plaintext string `json:"token"` 22 | Hash []byte `json:"-"` 23 | UserID int64 `json:"-"` 24 | Expiry time.Time `json:"expiry"` 25 | Scope string `json:"-"` 26 | } 27 | 28 | func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) { 29 | token := &Token{ 30 | UserID: userID, 31 | Expiry: time.Now().Add(ttl), 32 | Scope: scope, 33 | } 34 | randomBytes := make([]byte, 16) 35 | _, err := rand.Read(randomBytes) 36 | if err != nil { 37 | return nil, err 38 | } 39 | token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) 40 | hash := sha256.Sum256([]byte(token.Plaintext)) 41 | token.Hash = hash[:] 42 | 43 | return token, nil 44 | 45 | } 46 | 47 | func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) { 48 | v.Check(tokenPlaintext != "", "token", "must be provided") 49 | v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long") 50 | } 51 | 52 | type TokenModel struct { 53 | DB *sql.DB 54 | } 55 | 56 | // New() method is a method on the TokenModel type that generates a new token and inserts it into the tokens table. 57 | func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) { 58 | token, err := generateToken(userID, ttl, scope) 59 | if err != nil { 60 | return nil, err 61 | } 62 | err = m.Insert(token) 63 | return token, err 64 | } 65 | 66 | func (m TokenModel) Insert(token *Token) error { 67 | query := ` 68 | INSERT INTO tokens (hash, user_id, expiry, scope) 69 | VALUES ($1, $2, $3, $4) 70 | ` 71 | args := []interface{}{token.Hash, token.UserID, token.Expiry, token.Scope} 72 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 73 | defer cancel() 74 | _, err := m.DB.ExecContext(ctx, query, args...) 75 | return err 76 | } 77 | 78 | // DeleteAllForUser() method deletes all tokens for a specific user and scope 79 | func (m TokenModel) DeleteAllForUser(scope string, userID int64) error { 80 | query := ` 81 | DELETE FROM tokens 82 | WHERE scope = $1 AND user_id = $2 83 | ` 84 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 85 | defer cancel() 86 | _, err := m.DB.ExecContext(ctx, query, scope, userID) 87 | return err 88 | } 89 | -------------------------------------------------------------------------------- /internal/data/users.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "database/sql" 7 | "errors" 8 | "time" 9 | 10 | "dev.theenthusiast.text-bin/internal/validator" 11 | "golang.org/x/crypto/bcrypt" 12 | ) 13 | 14 | type User struct { 15 | ID int64 `json:"id"` 16 | CreatedAt time.Time `json:"created_at"` 17 | Name string `json:"name"` 18 | Email string `json:"email"` 19 | Password password `json:"-"` 20 | Activated bool `json:"activated"` 21 | Version int `json:"-"` 22 | Texts []Text `json:"texts,omitempty"` 23 | Comments []Comment `json:"comments,omitempty"` 24 | } 25 | 26 | type password struct { 27 | plaintext *string 28 | hash []byte 29 | } 30 | 31 | var ( 32 | ErrDuplicateEmail = errors.New("duplicate email") 33 | ) 34 | 35 | var AnonymousUser = &User{} 36 | 37 | func (u *User) IsAnonymous() bool { 38 | return u == AnonymousUser 39 | } 40 | 41 | func (p *password) Set(plaintextPassword string) error { 42 | hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) 43 | if err != nil { 44 | return err 45 | } 46 | p.plaintext = &plaintextPassword 47 | p.hash = hash 48 | 49 | return nil 50 | } 51 | 52 | func (p *password) Matches(plaintextPassword string) (bool, error) { 53 | err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) 54 | if err != nil { 55 | switch { 56 | case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): 57 | return false, nil 58 | default: 59 | return false, err 60 | } 61 | } 62 | return true, nil 63 | } 64 | 65 | func ValidateEmail(v *validator.Validator, email string) { 66 | v.Check(email != "", "email", "must be provided") 67 | v.Check(validator.Matches(email, validator.EmailRX), "email", "must be a valid email address") 68 | } 69 | 70 | func ValidatePasswordPlaintext(v *validator.Validator, password string) { 71 | v.Check(password != "", "password", "must be provided") 72 | v.Check(len(password) >= 8, "password", "must be at least 8 bytes long") 73 | v.Check(len(password) <= 72, "password", "must not be more than 72 bytes long") 74 | } 75 | 76 | func ValidateUser(v *validator.Validator, user *User) { 77 | v.Check(user.Name != "", "name", "must be provided") 78 | v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") 79 | // Call the standalone ValidateEmail() helper. 80 | ValidateEmail(v, user.Email) 81 | 82 | // If the plaintext password is not nil, call the standalone 83 | // ValidatePasswordPlaintext() helper. 84 | if user.Password.plaintext != nil { 85 | ValidatePasswordPlaintext(v, *user.Password.plaintext) 86 | } 87 | 88 | // If the password hash is ever nil, this will be due to a logic error in our 89 | // codebase (probably because we forgot to set a password for the user). It's a 90 | // useful sanity check to include here, but it's not a problem with the data 91 | // provided by the client. So rather than adding an error to the validation map we 92 | // raise a panic instead. 93 | if user.Password.hash == nil { 94 | panic("missing password hash for user") 95 | } 96 | } 97 | 98 | // create a UserModel struct to hold the connection pool 99 | type UserModel struct { 100 | DB *sql.DB 101 | } 102 | 103 | func (m UserModel) Insert(user *User) error { 104 | query := ` 105 | INSERT INTO users (name, email, password_hash, activated) 106 | VALUES ($1, $2, $3, $4) 107 | RETURNING id, created_at, version` 108 | 109 | args := []interface{}{user.Name, user.Email, user.Password.hash, user.Activated} 110 | 111 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 112 | defer cancel() 113 | 114 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Version) 115 | if err != nil { 116 | switch { 117 | case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: 118 | return ErrDuplicateEmail 119 | default: 120 | return err 121 | } 122 | } 123 | return nil 124 | } 125 | 126 | func (m UserModel) GetByEmail(email string) (*User, error) { 127 | query := ` 128 | SELECT id, created_at, name, email, password_hash, activated, version 129 | FROM users 130 | WHERE email = $1` 131 | 132 | var user User 133 | 134 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 135 | defer cancel() 136 | 137 | err := m.DB.QueryRowContext(ctx, query, email).Scan( 138 | &user.ID, 139 | &user.CreatedAt, 140 | &user.Name, 141 | &user.Email, 142 | &user.Password.hash, 143 | &user.Activated, 144 | &user.Version, 145 | ) 146 | if err != nil { 147 | switch { 148 | case errors.Is(err, sql.ErrNoRows): 149 | return nil, ErrRecordNotFound 150 | default: 151 | return nil, err 152 | } 153 | } 154 | 155 | // Fetch texts for the user 156 | textsQuery := ` 157 | SELECT id, created_at, title, content, format, expires, slug, version 158 | FROM texts 159 | WHERE user_id = $1` 160 | 161 | textsRows, err := m.DB.QueryContext(ctx, textsQuery, user.ID) 162 | if err != nil { 163 | return nil, err 164 | } 165 | defer textsRows.Close() 166 | 167 | for textsRows.Next() { 168 | var text Text 169 | err := textsRows.Scan( 170 | &text.ID, 171 | &text.CreatedAt, 172 | &text.Title, 173 | &text.Content, 174 | &text.Format, 175 | &text.Expires, 176 | &text.Slug, 177 | &text.Version, 178 | ) 179 | if err != nil { 180 | return nil, err 181 | } 182 | user.Texts = append(user.Texts, text) 183 | } 184 | 185 | // Fetch comments for the user 186 | commentsQuery := ` 187 | SELECT id, text_id, content, created_at, updated_at 188 | FROM comments 189 | WHERE user_id = $1` 190 | 191 | commentsRows, err := m.DB.QueryContext(ctx, commentsQuery, user.ID) 192 | if err != nil { 193 | return nil, err 194 | } 195 | defer commentsRows.Close() 196 | 197 | for commentsRows.Next() { 198 | var comment Comment 199 | err := commentsRows.Scan( 200 | &comment.ID, 201 | &comment.TextID, 202 | &comment.Content, 203 | &comment.CreatedAt, 204 | &comment.UpdatedAt, 205 | ) 206 | if err != nil { 207 | return nil, err 208 | } 209 | user.Comments = append(user.Comments, comment) 210 | } 211 | 212 | return &user, nil 213 | } 214 | 215 | func (m UserModel) Update(user *User) error { 216 | query := ` 217 | UPDATE users 218 | SET name = $1, email = $2, password_hash = $3, activated = $4, version = version + 1 219 | WHERE id = $5 AND version = $6 220 | RETURNING version` 221 | 222 | args := []interface{}{ 223 | user.Name, 224 | user.Email, 225 | user.Password.hash, 226 | user.Activated, 227 | user.ID, 228 | user.Version, 229 | } 230 | 231 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 232 | defer cancel() 233 | 234 | err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.Version) 235 | if err != nil { 236 | switch { 237 | case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: 238 | return ErrDuplicateEmail 239 | case errors.Is(err, sql.ErrNoRows): 240 | return ErrEditConflict 241 | default: 242 | return err 243 | } 244 | } 245 | return nil 246 | } 247 | 248 | func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) { 249 | tokenHash := sha256.Sum256([]byte(tokenPlaintext)) 250 | 251 | query := ` 252 | SELECT u.id, u.created_at, u.name, u.email, u.password_hash, u.activated, u.version 253 | FROM users u 254 | JOIN tokens t ON u.id = t.user_id 255 | WHERE t.hash = $1 AND t.scope = $2 AND t.expiry > $3` 256 | 257 | args := []interface{}{tokenHash[:], tokenScope, time.Now()} 258 | var user User 259 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 260 | defer cancel() 261 | 262 | err := m.DB.QueryRowContext(ctx, query, args...).Scan( 263 | &user.ID, 264 | &user.CreatedAt, 265 | &user.Name, 266 | &user.Email, 267 | &user.Password.hash, 268 | &user.Activated, 269 | &user.Version, 270 | ) 271 | if err != nil { 272 | switch { 273 | case errors.Is(err, sql.ErrNoRows): 274 | return nil, ErrRecordNotFound 275 | default: 276 | return nil, err 277 | } 278 | } 279 | return &user, nil 280 | } 281 | 282 | // DeleteUser deletes a user and all associated data 283 | func (m UserModel) DeleteUser(id int64) error { 284 | query := `DELETE FROM users WHERE id = $1` 285 | 286 | result, err := m.DB.Exec(query, id) 287 | if err != nil { 288 | return err 289 | } 290 | 291 | rowsAffected, err := result.RowsAffected() 292 | if err != nil { 293 | return err 294 | } 295 | 296 | if rowsAffected == 0 { 297 | return ErrRecordNotFound 298 | } 299 | 300 | return nil 301 | } 302 | -------------------------------------------------------------------------------- /internal/jsonlog/jsonlog.go: -------------------------------------------------------------------------------- 1 | package jsonlog 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "runtime/debug" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // define a Level type to represent the log level 13 | type Level int8 14 | 15 | const ( 16 | LevelInfo Level = iota 17 | LevelError 18 | LevelFatal 19 | LevelOff 20 | ) 21 | 22 | // define a humna friendly string method for the Level type 23 | func (l Level) String() string { 24 | switch l { 25 | case LevelInfo: 26 | return "INFO" 27 | case LevelError: 28 | return "ERROR" 29 | case LevelFatal: 30 | return "FATAL" 31 | default: 32 | return "" 33 | } 34 | } 35 | 36 | type Logger struct { 37 | out io.Writer 38 | minLevel Level 39 | mu sync.Mutex 40 | } 41 | 42 | // New creates a new Logger. The out variable represents the destination for the log output. 43 | func New(out io.Writer, minLevel Level) *Logger { 44 | return &Logger{out: out, minLevel: minLevel} 45 | } 46 | 47 | func (l *Logger) PrintInfo(message string, properties map[string]string) { 48 | l.print(LevelInfo, message, properties) 49 | } 50 | 51 | func (l *Logger) PrintError(err error, properties map[string]string) { 52 | l.print(LevelError, err.Error(), properties) 53 | } 54 | 55 | func (l *Logger) PrintFatal(err error, properties map[string]string) { 56 | l.print(LevelFatal, err.Error(), properties) 57 | os.Exit(1) // For entries at the FATAL level, we also terminate the application. 58 | } 59 | 60 | // Print is an internal method for writing the log entry. 61 | func (l *Logger) print(level Level, message string, properties map[string]string) (int, error) { 62 | // If the severity level of the log entry is below the minimum severity for the 63 | // logger, then return with no further action. 64 | if level < l.minLevel { 65 | return 0, nil 66 | } 67 | 68 | // Declare an anonymous struct holding the data for the log entry. 69 | aux := struct { 70 | Level string `json:"level"` 71 | Time string `json:"time"` 72 | Message string `json:"message"` 73 | Properties map[string]string `json:"properties,omitempty"` 74 | Trace string `json:"trace,omitempty"` 75 | }{ 76 | Level: level.String(), 77 | Time: time.Now().UTC().Format(time.RFC3339), 78 | Message: message, 79 | Properties: properties, 80 | } 81 | 82 | // Include a stack trace for entries at the ERROR and FATAL levels. 83 | if level >= LevelError { 84 | aux.Trace = string(debug.Stack()) 85 | } 86 | 87 | // Declare a line variable for holding the actual log entry text. 88 | var line []byte 89 | 90 | // Marshal the anonymous struct to JSON and store it in the line variable. If there 91 | // was a problem creating the JSON, set the contents of the log entry to be that 92 | // plain-text error message instead. 93 | line, err := json.Marshal(aux) 94 | if err != nil { 95 | line = []byte(LevelError.String() + ": unable to marshal log message:" + err.Error()) 96 | } 97 | 98 | // Lock the mutex so that no two writes to the output destination cannot happen 99 | // concurrently. If we don't do this, it's possible that the text for two or more 100 | // log entries will be intermingled in the output. 101 | l.mu.Lock() 102 | defer l.mu.Unlock() 103 | 104 | // Write the log entry followed by a newline. 105 | return l.out.Write(append(line, '\n')) 106 | } 107 | 108 | // We also implement a Write() method on our Logger type so that it satisfies the 109 | // io.Writer interface. This writes a log entry at the ERROR level with no additional 110 | // properties. 111 | func (l *Logger) Write(message []byte) (n int, err error) { 112 | return l.print(LevelError, string(message), nil) 113 | } 114 | -------------------------------------------------------------------------------- /internal/mailer/mailer.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "text/template" 7 | "time" 8 | 9 | "github.com/go-mail/mail/v2" 10 | ) 11 | 12 | //go:embed "templates" 13 | var templateFS embed.FS 14 | 15 | type Mailer struct { 16 | dialer *mail.Dialer 17 | sender string 18 | } 19 | 20 | func New(host string, port int, username, password, sender string) Mailer { 21 | dialer := mail.NewDialer(host, port, username, password) 22 | dialer.Timeout = 5 * time.Second 23 | 24 | return Mailer{ 25 | dialer: dialer, 26 | sender: sender, 27 | } 28 | } 29 | 30 | func (m Mailer) Send(recipient, templateFile string, data interface{}) error { 31 | tmpl, err := template.New("email").ParseFS(templateFS, "templates/"+templateFile) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | subject := new(bytes.Buffer) 37 | err = tmpl.ExecuteTemplate(subject, "subject", data) 38 | if err != nil { 39 | return err 40 | } 41 | plainBody := new(bytes.Buffer) 42 | err = tmpl.ExecuteTemplate(plainBody, "plainBody", data) 43 | if err != nil { 44 | return err 45 | } 46 | htmlBody := new(bytes.Buffer) 47 | err = tmpl.ExecuteTemplate(htmlBody, "htmlBody", data) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | msg := mail.NewMessage() 53 | msg.SetHeader("To", recipient) 54 | msg.SetHeader("From", m.sender) 55 | msg.SetHeader("Subject", subject.String()) 56 | msg.SetBody("text/plain", plainBody.String()) 57 | msg.AddAlternative("text/html", htmlBody.String()) 58 | 59 | // Try sending the email up to three times before aborting and returning the final 60 | // error. We sleep for 500 milliseconds between each attempt. 61 | for i := 1; i <= 3; i++ { 62 | err = m.dialer.DialAndSend(msg) 63 | // If everything worked, return nil. 64 | if nil == err { 65 | return nil 66 | } 67 | 68 | // If it didn't work, sleep for a short time and retry. 69 | time.Sleep(500 * time.Millisecond) 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/mailer/templates/token_password_reset.tmpl: -------------------------------------------------------------------------------- 1 | {{define "subject"}}Reset your TextBin password{{end}} 2 | 3 | {{define "plainBody"}} 4 | Hi, 5 | 6 | We have provided a password reset token for you to reset your password. 7 | 8 | {"token": "{{.passwordResetToken}}"} 9 | 10 | Please note that this is a one-time use token and it will expire in 45 minutes. 11 | 12 | Thanks, 13 | 14 | The TextBin Team 15 | {{end}} 16 | 17 | {{define "htmlBody"}} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |

Hi,

26 |

We have provided a password reset token for you to reset your password.

27 |

28 |     {"token": "{{.passwordResetToken}}"}
29 |     
30 |

Please note that this is a one-time use token and it will expire in 45 minutes.

31 |

Thanks,

32 |

The TextBin Team

33 | 34 | 35 | {{end}} 36 | -------------------------------------------------------------------------------- /internal/mailer/templates/user_welcome.tmpl: -------------------------------------------------------------------------------- 1 | {{define "subject"}}Welcome to TextBin!{{end}} 2 | 3 | {{define "plainBody"}} 4 | Hi, 5 | 6 | Thanks for signing up for a TextBin account. We're excited to have you on board! 7 | 8 | For future reference, your user ID number is {{.userID}}. 9 | 10 | Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON 11 | body to activate your account: 12 | 13 | {"token": "{{.activationToken}}"} 14 | 15 | Please note that this is a one-time use token and it will expire in 3 days. 16 | 17 | Thanks, 18 | 19 | The TextBin Team 20 | {{end}} 21 | 22 | {{define "htmlBody"}} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |

Hi,

33 |

Thanks for signing up for a TextBin account. We're excited to have you on board!

34 |

For future reference, your user ID number is {{.userID}}.

35 |

Please send a request to the PUT /v1/users/activated endpoint with the 36 | following JSON body to activate your account:

37 |

38 |     {"token": "{{.activationToken}}"}
39 |     
40 |

Please note that this is a one-time use token and it will expire in 3 days.

41 |

Thanks,

42 |

The TextBin Team

43 | 44 | 45 | 46 | {{end}} 47 | -------------------------------------------------------------------------------- /internal/validator/validator.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import "regexp" 4 | 5 | var ( 6 | EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 7 | ) 8 | 9 | // Validator struct will be used to hold the validation errors 10 | type Validator struct { 11 | Errors map[string]string 12 | } 13 | 14 | // New function will be used to create a new instance of Validator struct 15 | func New() *Validator { 16 | return &Validator{Errors: make(map[string]string)} 17 | } 18 | 19 | // Valid function will be used to check if the validation errors map is empty or not (if it is empty then return true) 20 | func (v *Validator) Valid() bool { 21 | return len(v.Errors) == 0 22 | } 23 | 24 | // AddError function will be used to add a new error message to the validation errors map 25 | func (v *Validator) AddError(field, message string) { 26 | if _, ok := v.Errors[field]; !ok { 27 | v.Errors[field] = message 28 | } 29 | } 30 | 31 | // Check function will be used to check if the given value is empty or not (if it is empty then add an error message to the validation errors map) 32 | func (v *Validator) Check(ok bool, field, message string) { 33 | if !ok { 34 | v.AddError(field, message) 35 | } 36 | } 37 | 38 | // In function will be used to check if the given value is in the list of valid values or not (if it is not in the list then add an error message to the validation errors map) 39 | func (v *Validator) In(value string, values ...string) bool { 40 | for i := range values { 41 | if value == values[i] { 42 | return true 43 | } 44 | } 45 | return false 46 | } 47 | 48 | // Matches function will be used to check if the given value matches the regular expression or not (if it does not match then add an error message to the validation errors map) 49 | func Matches(value string, rx *regexp.Regexp) bool { 50 | return rx.MatchString(value) 51 | } 52 | 53 | // Unique function will be used to check if the given value is unique in the list of values or not (if it is not unique then add an error message to the validation errors map) 54 | func (v *Validator) Unique(value string, values ...string) bool { 55 | uniqueValues := make(map[string]bool) 56 | for _, value := range values { 57 | uniqueValues[value] = true 58 | } 59 | return len(uniqueValues) == len(values) 60 | } 61 | -------------------------------------------------------------------------------- /migrations/000001_create_texts_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS texts; 2 | -------------------------------------------------------------------------------- /migrations/000001_create_texts_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS texts ( 2 | id bigserial PRIMARY KEY, 3 | created_at timestamp(0) with time zone NOT NULL DEFAULT now(), 4 | title text NOT NULL, 5 | content text NOT NULL, 6 | format text NOT NULL DEFAULT 'plaintext', 7 | expires timestamp(0) with time zone, 8 | slug text NOT NULL UNIQUE, 9 | version integer NOT NULL DEFAULT 1 10 | ); 11 | -------------------------------------------------------------------------------- /migrations/000002_create_users_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | -------------------------------------------------------------------------------- /migrations/000002_create_users_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id bigserial PRIMARY KEY, 3 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), 4 | name text NOT NULL, 5 | email citext UNIQUE NOT NULL, 6 | password_hash bytea NOT NULL, 7 | activated bool NOT NULL, 8 | version integer NOT NULL DEFAULT 1 9 | ); 10 | -------------------------------------------------------------------------------- /migrations/000003_create_tokens_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS tokens; 2 | -------------------------------------------------------------------------------- /migrations/000003_create_tokens_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS tokens( 2 | hash bytea PRIMARY KEY, 3 | user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, 4 | expiry timestamp(0) with time zone NOT NULL, 5 | scope text NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/000004_create_likes_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS likes; 2 | -------------------------------------------------------------------------------- /migrations/000004_create_likes_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS likes ( 2 | id bigserial PRIMARY KEY, 3 | user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, 4 | text_id bigint NOT NULL REFERENCES texts ON DELETE CASCADE, 5 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), 6 | UNIQUE (user_id, text_id) 7 | ); 8 | -------------------------------------------------------------------------------- /migrations/000005_create_comments_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS comments; 2 | -------------------------------------------------------------------------------- /migrations/000005_create_comments_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS comments ( 2 | id bigserial PRIMARY KEY, 3 | user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, 4 | text_id bigint NOT NULL REFERENCES texts ON DELETE CASCADE, 5 | content text NOT NULL, 6 | created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), 7 | updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW() 8 | ); 9 | -------------------------------------------------------------------------------- /migrations/000006_add_userid_to_texts_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts DROP COLUMN IF EXISTS user_id; 2 | -------------------------------------------------------------------------------- /migrations/000006_add_userid_to_texts_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts ADD COLUMN user_id bigint REFERENCES users(id); 2 | -------------------------------------------------------------------------------- /migrations/000007_add_index_to_texts_and_comments_table.down.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/The-Enthusiast-404/text-bin-backend/c80e205631eb89ad869765daf64dddc143d2f87f/migrations/000007_add_index_to_texts_and_comments_table.down.sql -------------------------------------------------------------------------------- /migrations/000007_add_index_to_texts_and_comments_table.up.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/The-Enthusiast-404/text-bin-backend/c80e205631eb89ad869765daf64dddc143d2f87f/migrations/000007_add_index_to_texts_and_comments_table.up.sql -------------------------------------------------------------------------------- /migrations/000008_add_isPrivate_column_to_text_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts DROP COLUMN IF EXISTS is_private; 2 | -------------------------------------------------------------------------------- /migrations/000008_add_isPrivate_column_to_text_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT false; 2 | -------------------------------------------------------------------------------- /migrations/000009_add_cascade_delete_to_user_relations.down.sql: -------------------------------------------------------------------------------- 1 | -- Remove CASCADE DELETE from texts table 2 | ALTER TABLE texts 3 | DROP CONSTRAINT IF EXISTS texts_user_id_fkey, 4 | ADD CONSTRAINT texts_user_id_fkey 5 | FOREIGN KEY (user_id) REFERENCES users(id); 6 | 7 | -- Remove CASCADE DELETE from comments table 8 | ALTER TABLE comments 9 | DROP CONSTRAINT IF EXISTS comments_user_id_fkey, 10 | ADD CONSTRAINT comments_user_id_fkey 11 | FOREIGN KEY (user_id) REFERENCES users(id); 12 | 13 | -- Remove CASCADE DELETE from likes table 14 | ALTER TABLE likes 15 | DROP CONSTRAINT IF EXISTS likes_user_id_fkey, 16 | ADD CONSTRAINT likes_user_id_fkey 17 | FOREIGN KEY (user_id) REFERENCES users(id); 18 | 19 | -- Remove CASCADE DELETE from tokens table 20 | ALTER TABLE tokens 21 | DROP CONSTRAINT IF EXISTS tokens_user_id_fkey, 22 | ADD CONSTRAINT tokens_user_id_fkey 23 | FOREIGN KEY (user_id) REFERENCES users(id); 24 | -------------------------------------------------------------------------------- /migrations/000009_add_cascade_delete_to_user_relations.up.sql: -------------------------------------------------------------------------------- 1 | -- Add CASCADE DELETE to texts table 2 | ALTER TABLE texts 3 | DROP CONSTRAINT IF EXISTS texts_user_id_fkey, 4 | ADD CONSTRAINT texts_user_id_fkey 5 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 6 | 7 | -- Add CASCADE DELETE to comments table 8 | ALTER TABLE comments 9 | DROP CONSTRAINT IF EXISTS comments_user_id_fkey, 10 | ADD CONSTRAINT comments_user_id_fkey 11 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 12 | 13 | -- Add CASCADE DELETE to likes table 14 | ALTER TABLE likes 15 | DROP CONSTRAINT IF EXISTS likes_user_id_fkey, 16 | ADD CONSTRAINT likes_user_id_fkey 17 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 18 | 19 | -- Add CASCADE DELETE to tokens table 20 | ALTER TABLE tokens 21 | DROP CONSTRAINT IF EXISTS tokens_user_id_fkey, 22 | ADD CONSTRAINT tokens_user_id_fkey 23 | FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; 24 | -------------------------------------------------------------------------------- /migrations/000010_add_encryption_salt_to_texts_table.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts 2 | DROP COLUMN IF EXISTS encryption_salt; 3 | -------------------------------------------------------------------------------- /migrations/000010_add_encryption_salt_to_texts_table.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE texts 2 | ADD COLUMN encryption_salt TEXT; 3 | --------------------------------------------------------------------------------