commit 6719f8a61a854fc0d1da86b0362e18698a72c3ef Author: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Mon Mar 31 16:17:35 2025 +0100 feat: initial commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..f596ef1 --- /dev/null +++ b/.air.toml @@ -0,0 +1,53 @@ + +root = "." +testdata_dir = "testdata" +tmp_dir = "bin" + +[build] + args_bin = [] + bin = "./bin/main" + cmd = "go build -o ./bin/main ./cmd/api" + delay = 1000 + exclude_dir = ["assets", "bin", "vendor", "testdata", "docs", "scripts"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = ['make gen-docs'] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a7f8dcb --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,50 @@ +## General Guidelines for Code Generation + +- **Overall Style:** + - Write clear, concise, and well-commented code. + - Follow idiomatic coding practices that prioritize readability, maintainability, and simplicity. + - Apply the DRY (Don't Repeat Yourself) principle to avoid duplication. + +## Backend (Go) Specifics + +- **Routing & Handlers:** + + - Use go-chi for routing and maintain a modular structure. + - Write handler functions that perform one clear task: parsing requests, interacting with PostgreSQL, and returning JSON responses. + - Include inline comments to explain complex logic and error handling. + +- **Error Handling & Patterns:** + + - Check for errors at each step and use context-aware patterns. + - Use proper naming conventions (camelCase for variables and PascalCase for types). + +- **Database & Caching:** + - When interacting with PostgreSQL, use environment variables (e.g., `DATABASE_URL`) to read connection details and use parameterized queries to prevent SQL injection. + - For Redis, initialize the client with context and perform error checking to ensure the connection is stable. + +## Swagger Documentation + +- **API Documentation:** + - Generate Swagger (OpenAPI) YAML snippets to document each endpoint, including parameters, expected responses, and error messages. + - Ensure the Swagger docs are clear and adhere to standardized formats. + +## Frontend (NuxtJS) Specifics + +- **Setup & Configuration:** + - Assume a client-side only NuxtJS application. + - Generate lightweight, reusable Vue components using the Composition API. + - Use async/await syntax for API calls and refer to environment variables (e.g., API base URL) for configuration. + +## Commit & PR Messaging + +- **Descriptive Messaging:** + - When generating commit messages or pull request descriptions, include: + - A short summary of the changes. + - The reason for the changes. + - Optionally, emojis to indicate the type of update (e.g., :sparkles: for features, :bug: for fixes). + +## Additional Guidance & Context + +- **Suggestions & Improvements:** + - When proposing code changes, suggest alternative approaches or improvements, particularly around error handling, security, and performance optimization. + - Ask clarifying questions if the context is unclear to ensure alignment with project requirements. diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml new file mode 100644 index 0000000..f446214 --- /dev/null +++ b/.github/workflows/audit.yaml @@ -0,0 +1,39 @@ +name: Audit + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + audit: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23.4" + + - name: Verify dependencies + run: go mod tidy + + - name: Build + run: go build -v ./... + + - name: Run go vet + run: go vet ./... + + - name: Install staticcheck + run: go install honnef.co/go/tools/cmd/staticcheck@latest + + - name: Run staticcheck + run: staticcheck ./... + + - name: Run Staticcheck + run: staticcheck ./... + + - name: Run tests + run: go test -race ./... diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 0000000..6829f6b --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,24 @@ +on: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +name: Release Please + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + # this assumes that you have created a personal access token + # (PAT) and configured it as a GitHub action secret named + # `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important). + token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} + # this is a built-in strategy in release-please, see "Action Inputs" + # for more options + release-type: simple diff --git a/.github/workflows/update-api-version.yaml b/.github/workflows/update-api-version.yaml new file mode 100644 index 0000000..47036d9 --- /dev/null +++ b/.github/workflows/update-api-version.yaml @@ -0,0 +1,32 @@ +name: Update Version and Release +on: + push: + branches: + - master + +permissions: + contents: write + +jobs: + update-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract latest version from CHANGELOG.md + id: get_version + run: | + VERSION=$(grep -oP '\[\K[0-9]+\.[0-9]+\.[0-9]+' CHANGELOG.md | head -1) + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Update version in cmd/api/main.go + run: | + sed -i "s/const version = \".*\"/const version = \"${{ steps.get_version.outputs.VERSION }}\"/" cmd/api/main.go + + - name: Commit and push if changed + run: | + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + git add cmd/api/main.go + git commit -m "Update version to ${{ steps.get_version.outputs.VERSION }}" || echo "No changes to commit" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1761c01 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.envrc \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d92fa6c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 1.0.0 (2025-03-31) + + +### Features + +* initial commit ([051aedd](https://github.com/FernandoJVideira/LK_API_Temp/commit/051aeddf7d1bf6d537630104bb51e3cef418bcae)) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc19623 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +include .envrc +MIGRATION_PATH = ./cmd/migrate/migrations + +.PHONY: test +test: + @go test -v ./... + +.PHONY: start +start: + @docker compose up -d + +.PHONY: stop +stop: + @docker compose down + +.PHONY: migrate-create +migration: + @migrate create -seq -ext sql -dir $(MIGRATION_PATH) $(filter-out $@,$(MAKECMDGOALS)) + +.PHONY: migrate-up +migrate-up: + @migrate -path=$(MIGRATION_PATH) -database=$(DB_ADDR) up + +.PHONY: migrate-down +migrate-down: + @migrate -path=$(MIGRATION_PATH) -database=$(DB_ADDR) down $(filter-out $@,$(MAKECMDGOALS)) + +.PHONY: seed +seed: + @go run cmd/migrate/seed/main.go + +.PHONY: gen-docs +gen-docs: + @swag init -g ./api/main.go -d cmd,internal && swag fmt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..143930e --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# LK API Template + +A modern, production-ready API template built with Go and React, featuring comprehensive authentication, mailing capabilities, and automatic API documentation. + +## 🚀 Features + +- **Backend (Go)** + + - Chi router for efficient HTTP routing + - Swagger/OpenAPI documentation + - SendGrid integration for email notifications + - Rate limiting support + - Redis caching + - PostgreSQL database integration + - JWT-based authentication + - Environment-based configuration + - Structured logging with Zap + - Hot reload support with Air + +- **Frontend (React + TypeScript)** + - Vite for fast development and building + - TypeScript support + - ESLint configuration for code quality + - Hot Module Replacement (HMR) + +## 📋 Prerequisites + +- Go 1.21 or higher +- Node.js 18 or higher +- PostgreSQL +- Redis +- SendGrid API key (for email functionality) + +## 🛠️ Installation + +1. Clone the repository: + +```bash +git clone https://github.com/yourusername/lk-api-template.git +cd lk-api-template +``` + +2. Install backend dependencies: + +```bash +go mod download +``` + +3. Install frontend dependencies: + +```bash +cd web +npm install +``` + +4. Set up environment variables: + +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +## 🚀 Development + +### Backend + +Run the backend server with hot reload: + +```bash +air +``` + +The API will be available at `http://localhost:8080` with the base path `/v1`. + +### Frontend + +Run the frontend development server: + +```bash +cd web +npm run dev +``` + +The frontend will be available at `http://localhost:5173`. + +## 📚 API Documentation + +The API documentation is automatically generated using Swagger. Once the server is running, you can access the Swagger UI at: diff --git a/bin/build-errors.log b/bin/build-errors.log new file mode 100644 index 0000000..70974a9 --- /dev/null +++ b/bin/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/bin/main b/bin/main new file mode 100755 index 0000000..35d2349 Binary files /dev/null and b/bin/main differ diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 0000000..ff1a4cf --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "errors" + "expvar" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/FernandoVideira/LK_API_Temp/docs" // This is required to autogenerate swagger docs + "github.com/FernandoVideira/LK_API_Temp/internal/auth" + "github.com/FernandoVideira/LK_API_Temp/internal/mailer" + "github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter" + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/FernandoVideira/LK_API_Temp/internal/store/cache" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + httpSwagger "github.com/swaggo/http-swagger/v2" // http-swagger middleware + + "go.uber.org/zap" +) + +type api struct { + config config + store store.Storage + cacheStore cache.Storage + logger *zap.SugaredLogger + mailer mailer.Client + authenticator auth.Authenticator + rateLimiter ratelimiter.Limiter +} + +type config struct { + addr string + db dbConfig + env string + apiURL string + mail mailConfig + frontEndURL string + auth authConfig + redisCfg redisConfig + rateLimiter ratelimiter.Config +} + +type mailConfig struct { + exp time.Duration + fromEmail string + sendGrid sendGridConfig +} + +type authConfig struct { + basic basicConfig + token tokenConfig +} + +type basicConfig struct { + username string + password string +} + +type tokenConfig struct { + secret string + exp time.Duration + iss string + aud string +} + +type redisConfig struct { + addr string + pw string + db int + enabled bool +} + +// TODO: Switch to previously made SMTP mailer +type sendGridConfig struct { + apiKey string +} + +type dbConfig struct { + addr string + maxOpenConns int + maxIdleConns int + maxIdleTime time.Duration +} + +func (api *api) mount() http.Handler { + r := chi.NewRouter() + // *CORS + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, //* Use this for development + //AllowedOrigins: []string{env.GetList("ALLOWED_ORIGINS", []string{"https://lk-api-temp.onrender.com"})}, //* Use this for production + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: false, + MaxAge: 300, + })) + r.Use(api.RateLimiterMiddleware) + + r.Use(middleware.Timeout(60 * time.Second)) + + r.Route("/v1", func(r chi.Router) { + + //r.With(api.BasicAuthMiddleware()).Get("/health", api.healthCheckHandler) + r.Get("/health", api.healthCheckHandler) + r.With(api.BasicAuthMiddleware()).Get("/debug/vars", expvar.Handler().ServeHTTP) + + docsURL := fmt.Sprintf("%s/swagger/doc.json", api.config.addr) + r.Get("/swagger/*", httpSwagger.Handler(httpSwagger.URL(docsURL))) + + r.Route("/posts", func(r chi.Router) { + r.Use(api.AuthTokenMiddleware) + r.Post("/", api.createPostHandler) + + r.Route("/{id}", func(r chi.Router) { + r.Use(api.postsContextMiddleware) + + r.Get("/", api.getPostHandler) + r.Patch("/", api.checkPostOwnership("moderator", api.updatePostHandler)) + r.Delete("/", api.checkPostOwnership("admin", api.deletePostHandler)) + r.Post("/comments", api.createCommentHandler) + }) + }) + + r.Route("/users", func(r chi.Router) { + r.Put("/activate/{token}", api.activateUserHandler) + + r.Route("/{id}", func(r chi.Router) { + r.Use(api.AuthTokenMiddleware) + + r.Get("/", api.getUserHandler) + r.Patch("/", api.checkProfileOwnership(api.updateUserHandler)) + r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler)) + r.Put(("/follow"), api.followUserHandler) + r.Put(("/unfollow"), api.unfollowUserHandler) + }) + r.Group(func(r chi.Router) { + r.Use(api.AuthTokenMiddleware) + r.Get("/feed", api.getUserFeedHandler) + }) + }) + + r.Route("/authentication", func(r chi.Router) { + r.Post("/user", api.registerUserHandler) + r.Post("/token", api.createTokenHandler) + + }) + }) + + return r +} + +func (api *api) run(mux http.Handler) error { + + //Docs + docs.SwaggerInfo.Version = "0.0.1" + docs.SwaggerInfo.Host = api.config.apiURL + docs.SwaggerInfo.BasePath = "/v1" + + srv := http.Server{ + Addr: api.config.addr, + Handler: mux, + WriteTimeout: 30 * time.Second, + ReadTimeout: 10 * time.Second, + IdleTimeout: time.Minute, + } + + shutdown := make(chan error) + + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + s := <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + api.logger.Infow("shutting down server", "signal", s.String()) + + shutdown <- srv.Shutdown(ctx) + + }() + + api.logger.Infow("server has started", "addr", api.config.addr, "env", api.config.env) + + err := srv.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + return err + } + + err = <-shutdown + if err != nil { + return err + } + + api.logger.Infow("server has been shutdown") + + return nil +} diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 0000000..3b7ff24 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter" +) + +func TestRateLimiterMiddleware(t *testing.T) { + cfg := config{ + rateLimiter: ratelimiter.Config{ + RequestsPerTimeFrame: 10, + TimeFrame: time.Second * 5, + Enabled: true, + }, + addr: ":8080", + } + + api := newTestApi(t, cfg) + ts := httptest.NewServer(api.mount()) + + defer ts.Close() + + client := ts.Client() + mockIP := "192.168.1.1" + marginOfError := 2 + + for i := 0; i < cfg.rateLimiter.RequestsPerTimeFrame+marginOfError; i++ { + req, err := http.NewRequest("GET", ts.URL+"/v1/health", nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + req.Header.Set("X-Forwarded-For", mockIP) + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to send request: %v", err) + } + defer resp.Body.Close() + + if i < cfg.rateLimiter.RequestsPerTimeFrame { + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } + } else { + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected status code %d, got %d", http.StatusTooManyRequests, resp.StatusCode) + } + } + } +} diff --git a/cmd/api/auth.go b/cmd/api/auth.go new file mode 100644 index 0000000..13ab266 --- /dev/null +++ b/cmd/api/auth.go @@ -0,0 +1,203 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "time" + + "github.com/FernandoVideira/LK_API_Temp/internal/mailer" + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type RegisterUserPayload struct { + FirstName string `json:"first_name" validate:"required,max=100"` + LastName string `json:"last_name" validate:"required,max=100"` + Username string `json:"username" validate:"required,max=100"` + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,password"` +} + +type UserWithToken struct { + *store.User + Token string `json:"token"` +} + +// RegisterUser godoc +// +// @Summary Register a new User +// @Description Register a new User +// @Tags authentication +// @Accept json +// @Produce json +// @Param payload body RegisterUserPayload true "User Credentials" +// @Success 201 {object} UserWithToken "User Registered" +// @Failure 400 {object} error "User payload error" +// @Failure 500 {object} error "Internal Server Error" +// @Router /authentication/user [post] +func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) { + var payload RegisterUserPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + user := &store.User{ + FirstName: payload.FirstName, + LastName: payload.LastName, + Username: payload.Username, + Email: payload.Email, + Role: store.Role{ + Name: "user", + }, + } + + // Hash the user password + if err := user.Password.Set(payload.Password); err != nil { + api.internalServerError(w, r, err) + return + } + + ctx := r.Context() + + plainToken := uuid.New().String() + + hash := sha256.Sum256([]byte(plainToken)) + hashedToken := hex.EncodeToString(hash[:]) + + // Store the user + err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp) + if err != nil { + switch { + case errors.Is(err, store.ErrDuplicateEmail): + api.badRequestError(w, r, err) + case errors.Is(err, store.ErrDuplicateUsername): + api.badRequestError(w, r, err) + default: + api.internalServerError(w, r, err) + } + return + } + + userWithToken := UserWithToken{ + User: user, + Token: plainToken, + } + + activationURL := fmt.Sprintf("%s/confirm/%s", api.config.frontEndURL, plainToken) + + isProdEnv := api.config.env == "production" + vars := struct { + ServiceName string + Username string + ActivationURL string + }{ + ServiceName: "LK_API_Temp", + Username: user.Username, + ActivationURL: activationURL, + } + + // Send Email + status, err := api.mailer.Send(mailer.UserWelcomeTemplate, user.Username, user.Email, vars, !isProdEnv) + if err != nil { + // Log the error + api.logger.Errorw("failed to send welcome email", "error", err) + + //Rollback the user creation if the email fails + if err := api.store.Users.Delete(ctx, user.ID); err != nil { + api.logger.Errorw("failed to rollback user creation", "error", err) + } + + api.internalServerError(w, r, err) + return + } + + api.logger.Infow("email sent", "status code", status) + + if err := api.jsonResponse(w, http.StatusCreated, userWithToken); err != nil { + api.internalServerError(w, r, err) + return + } +} + +type CredentialsPayload struct { + Email string `json:"email" validate:"required,email,max=255"` + Password string `json:"password" validate:"required,min=3,max=72"` +} + +// CreateTokenHandler godoc +// +// @Summary Create a new token +// @Description Create a new token for the user +// @Tags authentication +// @Accept json +// @Produce json +// @Param payload body CredentialsPayload true "User Credentials" +// @Success 201 {string} string "Token Created" +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /authentication/token [post] +func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) { + //* parse payload credentials + var payload CredentialsPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + //* fetch the user (check if the user exists) from the payload + user, err := api.store.Users.GetByEmail(r.Context(), payload.Email) + if err != nil { + switch err { + case store.ErrNotFound: + api.unauthorizedError(w, r, err) + default: + api.internalServerError(w, r, err) + } + return + } + + //* compare the password from the payload with the user password + if err := user.Password.Compare(payload.Password); err != nil { + api.unauthorizedError(w, r, err) + return + } + + claims := &jwt.MapClaims{ + "sub": user.ID, + "exp": time.Now().Add(api.config.auth.token.exp).Unix(), + "iat": time.Now().Unix(), + "nbf": time.Now().Unix(), + "iss": api.config.auth.token.iss, + "aud": api.config.auth.token.aud, + } + + //* generate a new token -> add the claims + token, err := api.authenticator.GenerateToken(claims) + if err != nil { + api.internalServerError(w, r, err) + return + } + + //* send the token back to the user + + if err := api.jsonResponse(w, http.StatusCreated, token); err != nil { + api.internalServerError(w, r, err) + return + } +} diff --git a/cmd/api/comments.go b/cmd/api/comments.go new file mode 100644 index 0000000..bed2e97 --- /dev/null +++ b/cmd/api/comments.go @@ -0,0 +1,58 @@ +package main + +import ( + "errors" + "net/http" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" +) + +type CreateCommentPayload struct { + Content string `json:"content" validate:"required, max=1000"` +} + +// CreateComment godo +// +// @Summary Creates a new Comment +// @Description Creates a new Comment +// @Tags comments +// @Accept json +// @Produce json +// @Param id path int true "Post ID" +// @Success 200 {object} store.Comment +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /posts/{id}/comments [post] +func (api *api) createCommentHandler(w http.ResponseWriter, r *http.Request) { + var payload CreateCommentPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + post := getPostFromContext(r) + if post == nil { + api.notFoundError(w, r, errors.New("post not found")) + return + } + + user := getUserFromContext(r) + + comment := &store.Comment{ + PostID: post.ID, + UserID: user.ID, + Content: payload.Content, + } + + if err := api.store.Comments.Create(r.Context(), comment); err != nil { + api.internalServerError(w, r, err) + return + } +} diff --git a/cmd/api/errors.go b/cmd/api/errors.go new file mode 100644 index 0000000..49dd7b9 --- /dev/null +++ b/cmd/api/errors.go @@ -0,0 +1,47 @@ +package main + +import ( + "net/http" +) + +func (api *api) internalServerError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Errorw("internal server error", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusInternalServerError, "internal server error") +} + +func (api *api) badRequestError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Warnw("bad request", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusInternalServerError, err.Error()) +} + +func (api *api) notFoundError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Warnw("not found", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusNotFound, "resource not found") +} + +func (api *api) conflictError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Errorw("conflict", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusConflict, "resource conflict") +} + +func (api *api) unauthorizedError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Warnw("unauthorized", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusUnauthorized, "unauthorized") +} + +func (api *api) unauthorizedBasicError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Warnw("unauthorized", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) + errorJSON(w, http.StatusUnauthorized, "basic unauthorized") +} + +func (api *api) forbiddenError(w http.ResponseWriter, r *http.Request, err error) { + api.logger.Warnw("forbidden", "method", r.Method, "path", r.URL.Path, "error", err.Error()) + errorJSON(w, http.StatusForbidden, "forbidden") +} + +func (api *api) rateLimitExceededError(w http.ResponseWriter, r *http.Request, retryAfter string) { + api.logger.Warnw("rate limit exceeded", "method", r.Method, "path", r.URL.Path) + w.Header().Set("Retry-After", retryAfter) + errorJSON(w, http.StatusTooManyRequests, "too many requests, retry after "+retryAfter) +} diff --git a/cmd/api/feed.go b/cmd/api/feed.go new file mode 100644 index 0000000..90c2783 --- /dev/null +++ b/cmd/api/feed.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "net/http" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" +) + +// GetFeed godoc +// +// @Summary Fetches the user feed +// @Description Fetches the user feed +// @Tags feed +// @Accept json +// @Produce json +// @Param limit query int false "Limit" +// @Param offset query int false "Offset" +// @Param sort query string false "Sort" +// @Param tags query string false "Tags" +// @Param search query string false "Search" +// @Param since query string false "Since" +// @Param until query string false "Until" +// @Success 200 {object} []store.PostWithMetadata +// @Failure 400 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /users/feed [get] +func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) { + fq := store.PaginatedFeedQuery{ + Limit: 20, + Offset: 0, + Sort: "desc", + Tags: []string{}, + Search: "", + } + + fq, err := fq.Parse(r) + if err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(fq); err != nil { + api.badRequestError(w, r, err) + return + } + + ctx := r.Context() + user := getUserFromContext(r) + log.Println(user) + feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq) + if err != nil { + api.internalServerError(w, r, err) + return + } + + if err := api.jsonResponse(w, http.StatusOK, feed); err != nil { + api.internalServerError(w, r, err) + } +} diff --git a/cmd/api/health.go b/cmd/api/health.go new file mode 100644 index 0000000..bac616b --- /dev/null +++ b/cmd/api/health.go @@ -0,0 +1,25 @@ +package main + +import ( + "net/http" +) + +// HealthCheck godoc +// +// @Summary Health Check +// @Description Health Check +// @Tags ops +// @Success 204 {string} string "ok" +// @Failure 500 {object} error "Internal Server Error" +// @Router /health [get] +func (api *api) healthCheckHandler(w http.ResponseWriter, r *http.Request) { + data := map[string]string{ + "status": "ok", + "env": api.config.env, + "version": version, + } + + if err := api.jsonResponse(w, http.StatusOK, data); err != nil { + api.internalServerError(w, r, err) + } +} diff --git a/cmd/api/json.go b/cmd/api/json.go new file mode 100644 index 0000000..db6c41c --- /dev/null +++ b/cmd/api/json.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "net/http" + "unicode" + + "github.com/go-playground/validator/v10" +) + +var Validate *validator.Validate + +func init() { + Validate = validator.New(validator.WithRequiredStructEnabled()) + Validate.RegisterValidation("password", validatePassword) +} + +func writeJSON(w http.ResponseWriter, status int, data any) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(data) +} + +func readJSON(w http.ResponseWriter, r *http.Request, data any) error { + maxBytes := 1_048_578 // 1MB + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + return decoder.Decode(data) +} + +func errorJSON(w http.ResponseWriter, status int, err string) error { + type envelope struct { + Error string `json:"error"` + } + + return writeJSON(w, status, &envelope{Error: err}) +} + +func (api *api) jsonResponse(w http.ResponseWriter, status int, data any) error { + type envelope struct { + Data any `json:"data"` + } + return writeJSON(w, status, &envelope{Data: data}) +} + +func validatePassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + + var ( + hasMinLen = len(password) >= 8 + hasMaxLen = len(password) <= 72 + hasUpper = false + hasLower = false + hasNumber = false + hasSymbol = false + ) + + for _, char := range password { + switch { + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsNumber(char): + hasNumber = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSymbol = true + } + } + + return hasMinLen && hasMaxLen && hasUpper && hasLower && hasNumber && hasSymbol +} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..61afed1 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "expvar" + "log" + "runtime" + "time" + + "github.com/FernandoVideira/LK_API_Temp/internal/auth" + "github.com/FernandoVideira/LK_API_Temp/internal/db" + "github.com/FernandoVideira/LK_API_Temp/internal/env" + "github.com/FernandoVideira/LK_API_Temp/internal/mailer" + "github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter" + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/FernandoVideira/LK_API_Temp/internal/store/cache" + "github.com/go-redis/redis/v8" + "go.uber.org/zap" +) + +const version = "" + +// @title LK API Template +// @description This is a template for building APIs with Go, Chi, and Swagger. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email support@swagger.io + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @BasePath /v1 +// +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name Authorization +// @description +func main() { + cfg := config{ + addr: env.GetString("ADDR", ":8080"), + apiURL: env.GetString("EXTERNAL_URL", "localhost:8080"), + frontEndURL: env.GetString("FRONTEND_URL", "http://localhost:5173"), + db: dbConfig{ + addr: env.GetString("DB_ADDR", "postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"), + maxOpenConns: env.GetInt("DB_MAX_OPEN_CONNS", 30), + maxIdleConns: env.GetInt("DB_MAX_IDLE_CONNS", 30), + maxIdleTime: env.GetDuration("DB_MAX_IDLE_TIME", "15m"), + }, + redisCfg: redisConfig{ + addr: env.GetString("REDIS_ADDR", "localhost:6379"), + pw: env.GetString("REDIS_PASSWORD", ""), + db: env.GetInt("REDIS_DB", 0), + enabled: env.GetBool("REDIS_ENABLED", false), + }, + env: env.GetString("ENV", "development"), + mail: mailConfig{ + exp: time.Hour * 24 * 3, + fromEmail: env.GetString("FROM_EMAIL", ""), + sendGrid: sendGridConfig{ + apiKey: env.GetString("SENDGRID_API_KEY", ""), + }, + }, + auth: authConfig{ + basic: basicConfig{ + username: env.GetString("BASIC_AUTH_USERNAME", "admin"), + password: env.GetString("BASIC_AUTH_PASSWORD", "admin"), + }, + token: tokenConfig{ + secret: env.GetString("AUTH_TOKEN_SECRET", "secret"), + exp: env.GetDuration("AUTH_TOKEN_EXP", "24h"), + iss: env.GetString("AUTH_TOKEN_ISS", "lkapi"), + aud: env.GetString("AUTH_TOKEN_AUD", "lkapi"), + }, + }, + rateLimiter: ratelimiter.Config{ + RequestsPerTimeFrame: env.GetInt("RATE_LIMITER_REQS_COUNT", 20), + TimeFrame: env.GetDuration("RATE_LIMITER_TIME_FRAME", "1m"), + Enabled: env.GetBool("RATE_LIMITER_ENABLED", true), + }, + } + + // Logger + logger := zap.Must(zap.NewProduction()).Sugar() + defer logger.Sync() + + // Database + db, err := db.New( + cfg.db.addr, + cfg.db.maxOpenConns, + cfg.db.maxIdleConns, + cfg.db.maxIdleTime, + ) + if err != nil { + logger.Fatal(err) + } + defer db.Close() + logger.Info("database connection established") + + // Cache + var redisDb *redis.Client + if cfg.redisCfg.enabled { + redisDb = cache.NewRedisClient(cfg.redisCfg.addr, cfg.redisCfg.pw, cfg.redisCfg.db) + logger.Info("redis connection established") + } + + // Rate Limiter + rateLimiter := ratelimiter.NewFixedWindowLimiter( + cfg.rateLimiter.RequestsPerTimeFrame, + cfg.rateLimiter.TimeFrame, + ) + + store := store.NewStorage(db) + cacheStorage := cache.NewRedisStorage(redisDb) + mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail) + + jwtAuthenticator := auth.NewJWTAuthenticator( + cfg.auth.token.secret, + cfg.auth.token.aud, + cfg.auth.token.iss, + ) + + api := api{ + config: cfg, + store: store, + cacheStore: cacheStorage, + logger: logger, + mailer: mailer, + authenticator: jwtAuthenticator, + rateLimiter: rateLimiter, + } + + // Metrics Collected + expvar.NewString("version").Set(version) + expvar.Publish("database", expvar.Func(func() any { + return db.Stats() + })) + expvar.Publish("redis", expvar.Func(func() any { + return redisDb.Info(context.Background(), "keyspace") + })) + expvar.Publish("goroutines", expvar.Func(func() any { + return runtime.NumGoroutine() + })) + + mux := api.mount() + log.Fatal(api.run(mux)) +} diff --git a/cmd/api/middleware.go b/cmd/api/middleware.go new file mode 100644 index 0000000..00b0c2f --- /dev/null +++ b/cmd/api/middleware.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/go-chi/chi/v5" + "github.com/golang-jwt/jwt/v5" +) + +func (api *api) BasicAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //* read the auth header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + api.unauthorizedBasicError(w, r, fmt.Errorf("missing Authorization header")) + return + } + + //* parse the header -> get the base64 + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Basic" { + api.unauthorizedBasicError(w, r, fmt.Errorf("invalid Authorization header")) + return + } + + //* decode the base64 + decoded, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + api.unauthorizedBasicError(w, r, err) + return + } + + username := api.config.auth.basic.username + pwd := api.config.auth.basic.password + + //* check the credentials + creds := strings.SplitN(string(decoded), ":", 2) + if len(creds) != 2 || creds[0] != username || creds[1] != pwd { + api.unauthorizedBasicError(w, r, fmt.Errorf("invalid credentials")) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func (api *api) AuthTokenMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + api.unauthorizedError(w, r, fmt.Errorf("missing Authorization header")) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + api.unauthorizedError(w, r, fmt.Errorf("invalid Authorization header")) + return + } + + token := parts[1] + + jwtToken, err := api.authenticator.ValidateToken(token) + if err != nil { + api.unauthorizedError(w, r, err) + return + } + + claims := jwtToken.Claims.(jwt.MapClaims) + + userID, err := strconv.ParseInt(fmt.Sprintf("%.f", claims["sub"]), 10, 64) + if err != nil { + api.unauthorizedError(w, r, fmt.Errorf("invalid token")) + return + } + + ctx := r.Context() + user, err := api.getUser(ctx, userID) + if err != nil { + api.unauthorizedError(w, r, err) + return + } + + ctx = context.WithValue(ctx, userContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (api *api) postsContextMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idParam := chi.URLParam(r, "id") + + id, err := strconv.ParseInt(idParam, 10, 64) + if err != nil { + api.badRequestError(w, r, err) + return + } + ctx := r.Context() + + post, err := api.store.Posts.GetByID(ctx, id) + if err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + api.notFoundError(w, r, err) + default: + api.internalServerError(w, r, err) + } + return + } + + ctx = context.WithValue(ctx, postCtx, post) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (api *api) checkPostOwnership(requiredRole string, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + post := getPostFromContext(r) + + if user.ID == post.UserID { + next.ServeHTTP(w, r) + return + } + + allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole) + if err != nil { + api.internalServerError(w, r, err) + return + } + + if !allowed { + api.forbiddenError(w, r, fmt.Errorf("forbidden")) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + profile := getUserFromContext(r) + + if user.ID == profile.ID { + next.ServeHTTP(w, r) + return + } + + api.forbiddenError(w, r, fmt.Errorf("forbidden")) + }) +} + +func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) { + role, err := api.store.Roles.GetByName(ctx, roleName) + if err != nil { + return false, err + } + + return user.Role.Level >= role.Level, nil +} + +func (api *api) getUser(ctx context.Context, id int64) (*store.User, error) { + if !api.config.redisCfg.enabled { + return api.store.Users.GetByID(ctx, id) + } + + user, err := api.cacheStore.Users.Get(ctx, id) + if err != nil { + return nil, err + } + + if user == nil { + user, err = api.store.Users.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if err := api.cacheStore.Users.Set(ctx, user); err != nil { + return nil, err + } + } + + return user, nil +} + +func (api *api) RateLimiterMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if api.config.rateLimiter.Enabled { + if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow { + api.rateLimitExceededError(w, r, retryAfter.String()) + return + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/api/posts.go b/cmd/api/posts.go new file mode 100644 index 0000000..38c1444 --- /dev/null +++ b/cmd/api/posts.go @@ -0,0 +1,197 @@ +package main + +import ( + "errors" + "net/http" + "strconv" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/go-chi/chi/v5" +) + +type postKey string + +const postCtx postKey = "post" + +type CreatePostPayload struct { + Title string `json:"title" validate:"required,max=100"` + Content string `json:"content" validate:"required,max=1000"` + Tags []string `json:"tags"` +} + +// CreatePost godoc +// +// @Summary Creates a new Post +// @Description Creates a new Post +// @Tags posts +// @Accept json +// @Produce json +// @Param in body CreatePostPayload true "Post Payload" +// @Success 200 {object} store.Post +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /posts [post] +func (api *api) createPostHandler(w http.ResponseWriter, r *http.Request) { + var payload CreatePostPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + user := getUserFromContext(r) + + post := &store.Post{ + UserID: user.ID, + Title: payload.Title, + Content: payload.Content, + Tags: payload.Tags, + } + + ctx := r.Context() + + if err := api.store.Posts.Create(ctx, post); err != nil { + api.internalServerError(w, r, err) + return + } + + if err := api.jsonResponse(w, http.StatusCreated, post); err != nil { + api.internalServerError(w, r, err) + return + } +} + +// GetPost godoc +// +// @Summary Fetches a Post +// @Description Fetches a Post by ID +// @Tags posts +// @Accept json +// @Produce json +// @Param id path int true "Post ID" +// @Success 200 {object} store.Post +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /posts/{id} [get] +func (api *api) getPostHandler(w http.ResponseWriter, r *http.Request) { + post := getPostFromContext(r) + + comments, err := api.store.Comments.GetByPostID(r.Context(), post.ID) + if err != nil { + api.internalServerError(w, r, err) + return + } + + post.Comments = comments + + if err := api.jsonResponse(w, http.StatusOK, post); err != nil { + api.internalServerError(w, r, err) + return + } +} + +type UpdatePostPayload struct { + Title *string `json:"title" validate:"omitempty,max=100"` + Content *string `json:"content" validate:"omitempty,max=1000"` +} + +// UpdatePost godoc +// +// @Summary Update a Post +// @Description Update a Post by ID +// @Tags posts +// @Accept json +// @Produce json +// @Param id path int true "Post ID" +// @Param in body UpdatePostPayload true "Post Payload" +// @Success 200 {object} store.Post +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /posts/{id} [patch] +func (api *api) updatePostHandler(w http.ResponseWriter, r *http.Request) { + post := getPostFromContext(r) + + var payload UpdatePostPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if payload.Content != nil { + post.Content = *payload.Content + } + + if payload.Title != nil { + post.Title = *payload.Title + } + + if err := api.store.Posts.Update(r.Context(), post); err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + api.conflictError(w, r, err) + default: + api.internalServerError(w, r, err) + } + return + } + + if err := api.jsonResponse(w, http.StatusOK, post); err != nil { + api.internalServerError(w, r, err) + return + } +} + +// DeletePost godoc +// +// @Summary Deletes a Post +// @Description Deletse a Post by ID +// @Tags posts +// @Accept json +// @Produce json +// @Param id path int true "Post ID" +// @Success 204 {object} string +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /posts/{id} [delete] +func (api *api) deletePostHandler(w http.ResponseWriter, r *http.Request) { + idParam := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idParam, 10, 64) + if err != nil { + api.badRequestError(w, r, err) + return + } + ctx := r.Context() + + if err := api.store.Posts.Delete(ctx, id); err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + api.notFoundError(w, r, err) + default: + api.internalServerError(w, r, err) + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func getPostFromContext(r *http.Request) *store.Post { + post, _ := r.Context().Value(postCtx).(*store.Post) + return post +} diff --git a/cmd/api/test_utils.go b/cmd/api/test_utils.go new file mode 100644 index 0000000..a90c9a8 --- /dev/null +++ b/cmd/api/test_utils.go @@ -0,0 +1,41 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/FernandoVideira/LK_API_Temp/internal/auth" + "github.com/FernandoVideira/LK_API_Temp/internal/ratelimiter" + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/FernandoVideira/LK_API_Temp/internal/store/cache" + "go.uber.org/zap" +) + +func newTestApi(t *testing.T, cfg config) *api { + t.Helper() + + logger := zap.NewNop().Sugar() + mockStore := store.NewMockStorage() + mockCache := cache.NewMockCache() + testAuth := &auth.TestAuthenticator{} + rateLimiter := ratelimiter.NewFixedWindowLimiter( + cfg.rateLimiter.RequestsPerTimeFrame, + cfg.rateLimiter.TimeFrame, + ) + + return &api{ + logger: logger, + store: mockStore, + cacheStore: mockCache, + authenticator: testAuth, + config: cfg, + rateLimiter: rateLimiter, + } +} + +func executeRequest(req *http.Request, mux http.Handler) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + return rr +} diff --git a/cmd/api/users.go b/cmd/api/users.go new file mode 100644 index 0000000..78dfc40 --- /dev/null +++ b/cmd/api/users.go @@ -0,0 +1,302 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "strconv" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/go-chi/chi/v5" +) + +type userKey string + +const userContextKey userKey = "user" + +// GetUser godoc +// +// @Summary Fetches a user Profile +// @Description Fetches a user Profile by ID +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} store.User +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /users/{id} [get] +func (api *api) getUserHandler(w http.ResponseWriter, r *http.Request) { + userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || userID < 1 { + api.badRequestError(w, r, err) + return + } + ctx := r.Context() + + user, err := api.getUser(ctx, userID) + if err != nil { + switch err { + case store.ErrNotFound: + api.notFoundError(w, r, err) + return + default: + api.internalServerError(w, r, err) + return + } + } + + if err := api.jsonResponse(w, http.StatusOK, user); err != nil { + api.internalServerError(w, r, err) + } +} + +type updateUserPayload struct { + FirstName *string `json:"first_name" validate:"omitempty,max=100"` + LastName *string `json:"last_name" validate:"omitempty,max=100"` + Username *string `json:"username" validate:"omitempty,max=100"` + Email *string `json:"email" validate:"omitempty,email,max=255"` +} + +// Update godoc +// +// @Summary Update a user Profile +// @Description Update a user Profile by ID +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Param payload body updateUserPayload true "User Profile" +// @Success 200 {object} store.User +// @Failure 400 {object} error +// @Failure 404 {object} error +// @Failure 500 {object} error +// @Security ApiKeyAuth +// @Router /users/{id} [patch] +func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + log.Println(user) + + var payload updateUserPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + user = validateUser(payload, user) + + if err := api.updateUser(r.Context(), user); err != nil { + api.internalServerError(w, r, err) + return + } + + if err := api.jsonResponse(w, http.StatusOK, user); err != nil { + api.internalServerError(w, r, err) + } +} + +func (api *api) updateUser(ctx context.Context, user *store.User) error { + if err := api.store.Users.Update(ctx, user); err != nil { + return err + } + + api.cacheStore.Users.Delete(ctx, user.ID) + return nil +} + +// FollowUser godoc +// +// @Summary Follows a user +// @Description Follows a user by ID +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 204 {string} string "User followed" +// @Failure 400 {object} error "User not found" +// @Router /users/{id}/follow [put] +func (api *api) followUserHandler(w http.ResponseWriter, r *http.Request) { + followee := getUserFromContext(r) + followedID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + api.badRequestError(w, r, err) + return + } + + if err := api.store.Followers.Follow(r.Context(), followee.ID, followedID); err != nil { + switch { + case errors.Is(err, store.ErrConflict): + api.conflictError(w, r, err) + return + default: + api.internalServerError(w, r, err) + return + } + } + + if err := api.jsonResponse(w, http.StatusNoContent, nil); err != nil { + api.internalServerError(w, r, err) + } +} + +// UnollowUser godoc +// +// @Summary Unfollows a user +// @Description Unfollows a user by ID +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 204 {string} string "User unfollowed" +// @Failure 400 {object} error "User payload error" +// @Failure 404 {object} error "User not found" +// @Router /users/{id}/unfollow [put] +func (api *api) unfollowUserHandler(w http.ResponseWriter, r *http.Request) { + followee := getUserFromContext(r) + followedUserID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + api.badRequestError(w, r, err) + return + } + + if err := api.store.Followers.Unfollow(r.Context(), followee.ID, followedUserID); err != nil { + switch { + case errors.Is(err, store.ErrNotFound): + api.conflictError(w, r, err) + return + default: + api.internalServerError(w, r, err) + return + } + } + + w.WriteHeader(http.StatusNoContent) + if err := api.jsonResponse(w, http.StatusNoContent, nil); err != nil { + api.internalServerError(w, r, err) + } +} + +// ActivateUser godoc +// +// @Summary Activate/Register a user +// @Description Activate/Register a user by invitation token +// @Tags users +// @Accept json +// @Produce json +// @Param token path string true "Invitation Token" +// @Success 204 {string} string "User activated" +// @Failure 404 {object} error "User not found" +// @Failure 500 {object} error "Internal Server Error" +// @Security ApiKeyAuth +// @Router /users/activate/{token} [put] +func (api *api) activateUserHandler(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + + err := api.store.Users.Activate(r.Context(), token) + if err != nil { + switch err { + case store.ErrNotFound: + api.notFoundError(w, r, err) + return + default: + api.internalServerError(w, r, err) + return + } + } + + w.WriteHeader(http.StatusNoContent) + if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil { + api.internalServerError(w, r, err) + } +} + +type updateUserPasswordPayload struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,password,nefield=CurrentPassword"` +} + +// UpdatePassword godoc +// +// @Summary Update user password +// @Description Update user password with verification +// @Description Password must: +// @Description - Be at least 8 characters long +// @Description - Contain at least one uppercase letter +// @Description - Contain at least one lowercase letter +// @Description - Contain at least one number +// @Description - Contain at least one special character +// @Description - Be different from the current password +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Param payload body updateUserPasswordPayload true "Password Update" +// @Success 204 {string} string "Password updated successfully" +// @Failure 400 {object} error "Invalid request" +// @Failure 401 {object} error "Unauthorized" +// @Failure 404 {object} error "User not found" +// @Failure 500 {object} error "Internal server error" +// @Security ApiKeyAuth +// @Router /users/{id}/password [patch] +func (api *api) updatePasswordHandler(w http.ResponseWriter, r *http.Request) { + user := getUserFromContext(r) + + var payload updateUserPasswordPayload + if err := readJSON(w, r, &payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + api.badRequestError(w, r, err) + return + } + + if err := user.Password.Compare(payload.CurrentPassword); err != nil { + api.unauthorizedError(w, r, err) + return + } + + if err := user.Password.Set(payload.NewPassword); err != nil { + api.internalServerError(w, r, err) + return + } + + if err := api.updateUser(r.Context(), user); err != nil { + api.internalServerError(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) + if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil { + api.internalServerError(w, r, err) + } +} + +func validateUser(payload updateUserPayload, usrToUpdate *store.User) *store.User { + if payload.Email != nil { + usrToUpdate.Email = *payload.Email + } + + if payload.FirstName != nil { + usrToUpdate.FirstName = *payload.FirstName + } + + if payload.LastName != nil { + usrToUpdate.LastName = *payload.LastName + } + + if payload.Username != nil { + usrToUpdate.Username = *payload.Username + } + + return usrToUpdate +} + +func getUserFromContext(r *http.Request) *store.User { + user, _ := r.Context().Value(userContextKey).(*store.User) + return user +} diff --git a/cmd/api/users_test.go b/cmd/api/users_test.go new file mode 100644 index 0000000..0d86ddc --- /dev/null +++ b/cmd/api/users_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "net/http" + "testing" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/FernandoVideira/LK_API_Temp/internal/store/cache" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetUser(t *testing.T) { + withReddis := config{ + redisCfg: redisConfig{ + enabled: true, + }, + } + + api := newTestApi(t, withReddis) + mux := api.mount() + + testToken, err := api.authenticator.GenerateToken(nil) + if err != nil { + t.Fatal(err) + } + + t.Run("should not allow unauthenticated requests", func(t *testing.T) { + + mockStore := api.store.Users.(*store.MockUserStore) + mockStore.On("GetByID", int64(1)).Return(nil, nil) + + req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil) + if err != nil { + t.Fatal(err) + } + + rr := executeRequest(req, mux) + + assert.Equal(t, http.StatusUnauthorized, rr.Code, "should not allow unauthenticated requests") + + mockStore.AssertNotCalled(t, "GetByID") + + mockStore.Calls = nil // Reset the mock calls + }) + + t.Run("should allow authenticated requests", func(t *testing.T) { + mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore) + mockStore := api.store.Users.(*store.MockUserStore) + + mockCacheStore.On("Get", int64(1)).Return(nil, nil) + mockCacheStore.On("Set", mock.Anything, mock.Anything).Return(nil) + + mockStore.On("GetByID", int64(1)).Return(&store.User{ID: 1}, nil) + mockStore.On("GetByID", int64(42)).Return(nil, store.ErrNotFound) + + req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", "Bearer "+testToken) + rr := executeRequest(req, mux) + + assert.Equal(t, http.StatusOK, rr.Code, "should allow authenticated requests") + + mockStore.AssertNumberOfCalls(t, "GetByID", 2) + + mockCacheStore.Calls = nil // Reset the mock calls + }) + + t.Run("should hit the cache first and if not found add to cache", func(t *testing.T) { + mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore) + + mockCacheStore.On("Get", int64(42)).Return(nil, nil) + mockCacheStore.On("Get", int64(1)).Return(nil, nil) + mockCacheStore.On("Set", mock.Anything, mock.Anything).Return(nil) + + req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", "Bearer "+testToken) + rr := executeRequest(req, mux) + + assert.Equal(t, http.StatusOK, rr.Code, "should hit the cache first and if not found add to cache") + + mockCacheStore.AssertNumberOfCalls(t, "Get", 2) + + mockCacheStore.Calls = nil // Reset the mock calls + }) + + t.Run("should not hit the cache if it is turned off", func(t *testing.T) { + withReddis := config{ + redisCfg: redisConfig{ + enabled: false, + }, + } + + api := newTestApi(t, withReddis) + mux := api.mount() + + testToken, err := api.authenticator.GenerateToken(nil) + if err != nil { + t.Fatal(err) + } + + mockCacheStore := api.cacheStore.Users.(*cache.MockUserStore) + mockStore := api.store.Users.(*store.MockUserStore) + + mockStore.On("GetByID", int64(1)).Return(nil, nil) + + req, err := http.NewRequest(http.MethodGet, "/v1/users/1", nil) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("Authorization", "Bearer "+testToken) + rr := executeRequest(req, mux) + + assert.Equal(t, http.StatusOK, rr.Code, "should not hit the cache if it is turned off") + + mockCacheStore.AssertNotCalled(t, "Get") + + mockCacheStore.Calls = nil // Reset the mock calls + }) +} diff --git a/cmd/migrate/migrations/000001_create_users.down.sql b/cmd/migrate/migrations/000001_create_users.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/cmd/migrate/migrations/000001_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/cmd/migrate/migrations/000001_create_users.up.sql b/cmd/migrate/migrations/000001_create_users.up.sql new file mode 100644 index 0000000..6ce3380 --- /dev/null +++ b/cmd/migrate/migrations/000001_create_users.up.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS users ( + id bigserial PRIMARY KEY, + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + username VARCHAR(255) UNIQUE NOT NULL, + email citext UNIQUE NOT NULL, + password bytea NOT NULL, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW() +); + + diff --git a/cmd/migrate/migrations/000002_posts_create.down.sql b/cmd/migrate/migrations/000002_posts_create.down.sql new file mode 100644 index 0000000..52ac968 --- /dev/null +++ b/cmd/migrate/migrations/000002_posts_create.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS posts; \ No newline at end of file diff --git a/cmd/migrate/migrations/000002_posts_create.up.sql b/cmd/migrate/migrations/000002_posts_create.up.sql new file mode 100644 index 0000000..34bf2df --- /dev/null +++ b/cmd/migrate/migrations/000002_posts_create.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS posts ( + id bigserial PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + user_id bigint NOT NULL, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000003_update_posts_with_tags.down.sql b/cmd/migrate/migrations/000003_update_posts_with_tags.down.sql new file mode 100644 index 0000000..eea0121 --- /dev/null +++ b/cmd/migrate/migrations/000003_update_posts_with_tags.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts + DROP COLUMN tags; \ No newline at end of file diff --git a/cmd/migrate/migrations/000003_update_posts_with_tags.up.sql b/cmd/migrate/migrations/000003_update_posts_with_tags.up.sql new file mode 100644 index 0000000..ae3fba7 --- /dev/null +++ b/cmd/migrate/migrations/000003_update_posts_with_tags.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts + ADD COLUMN tags VARCHAR(100)[] NOT NULL DEFAULT '{}'; \ No newline at end of file diff --git a/cmd/migrate/migrations/000004_add_comments.down.sql b/cmd/migrate/migrations/000004_add_comments.down.sql new file mode 100644 index 0000000..7c49a6c --- /dev/null +++ b/cmd/migrate/migrations/000004_add_comments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS Comments; \ No newline at end of file diff --git a/cmd/migrate/migrations/000004_add_comments.up.sql b/cmd/migrate/migrations/000004_add_comments.up.sql new file mode 100644 index 0000000..b077b73 --- /dev/null +++ b/cmd/migrate/migrations/000004_add_comments.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS Comments ( + id bigserial PRIMARY KEY, + post_id bigserial NOT NULL, + user_id bigserial NOT NULL, + content TEXT NOT NULL, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + updated_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + FOREIGN KEY (post_id) REFERENCES Posts(id), + FOREIGN KEY (user_id) REFERENCES Users(id) +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000005_add_version_posts.down.sql b/cmd/migrate/migrations/000005_add_version_posts.down.sql new file mode 100644 index 0000000..c7e4efe --- /dev/null +++ b/cmd/migrate/migrations/000005_add_version_posts.down.sql @@ -0,0 +1 @@ +ALTER TABLE posts DROP COLUMN version; \ No newline at end of file diff --git a/cmd/migrate/migrations/000005_add_version_posts.up.sql b/cmd/migrate/migrations/000005_add_version_posts.up.sql new file mode 100644 index 0000000..7a7b52d --- /dev/null +++ b/cmd/migrate/migrations/000005_add_version_posts.up.sql @@ -0,0 +1 @@ +ALTER TABLE posts ADD COLUMN version INT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/cmd/migrate/migrations/000006_add_followers_table.down.sql b/cmd/migrate/migrations/000006_add_followers_table.down.sql new file mode 100644 index 0000000..e58752f --- /dev/null +++ b/cmd/migrate/migrations/000006_add_followers_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS followers; \ No newline at end of file diff --git a/cmd/migrate/migrations/000006_add_followers_table.up.sql b/cmd/migrate/migrations/000006_add_followers_table.up.sql new file mode 100644 index 0000000..ee70375 --- /dev/null +++ b/cmd/migrate/migrations/000006_add_followers_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS followers ( + user_id bigint NOT NULL, + follower_id bigint NOT NULL, + created_at timestamp(0) with time zone NOT NULL DEFAULT NOW(), + + PRIMARY KEY (user_id, follower_id), + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (follower_id) REFERENCES users (id) +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000007_add_indexes.down.sql b/cmd/migrate/migrations/000007_add_indexes.down.sql new file mode 100644 index 0000000..f53b7ba --- /dev/null +++ b/cmd/migrate/migrations/000007_add_indexes.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS idx_comments_content; +DROP INDEX IF EXISTS idx_comments_post_id; + +DROP INDEX IF EXISTS idx_posts_title; +DROP INDEX IF EXISTS idx_posts_tags; +DROP INDEX IF EXISTS idx_posts_user_id; + +DROP INDEX IF EXISTS idx_users_username; \ No newline at end of file diff --git a/cmd/migrate/migrations/000007_add_indexes.up.sql b/cmd/migrate/migrations/000007_add_indexes.up.sql new file mode 100644 index 0000000..e847b4e --- /dev/null +++ b/cmd/migrate/migrations/000007_add_indexes.up.sql @@ -0,0 +1,19 @@ +-- Indexes are used to speed up the query process. They are used to quickly locate the rows in a table that match the search condition. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_comments_content ON comments USING gin (content gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_posts_title ON posts USING gin (title gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING gin (tags); + + +CREATE INDEX IF NOT EXISTS idx_users_username ON users (username); + +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); + +CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id); + + + diff --git a/cmd/migrate/migrations/000008_add_invitation.down.sql b/cmd/migrate/migrations/000008_add_invitation.down.sql new file mode 100644 index 0000000..b860980 --- /dev/null +++ b/cmd/migrate/migrations/000008_add_invitation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS invitations; \ No newline at end of file diff --git a/cmd/migrate/migrations/000008_add_invitation.up.sql b/cmd/migrate/migrations/000008_add_invitation.up.sql new file mode 100644 index 0000000..9d810ef --- /dev/null +++ b/cmd/migrate/migrations/000008_add_invitation.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS invitations ( + token bytea PRIMARY KEY, + user_id bigint NOT NULL +); + diff --git a/cmd/migrate/migrations/000009_add_active_users.down.sql b/cmd/migrate/migrations/000009_add_active_users.down.sql new file mode 100644 index 0000000..ee60708 --- /dev/null +++ b/cmd/migrate/migrations/000009_add_active_users.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE + users +DROP COLUMN + is_active; \ No newline at end of file diff --git a/cmd/migrate/migrations/000009_add_active_users.up.sql b/cmd/migrate/migrations/000009_add_active_users.up.sql new file mode 100644 index 0000000..45d40d2 --- /dev/null +++ b/cmd/migrate/migrations/000009_add_active_users.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE + users +ADD COLUMN + is_active boolean NOT NULL DEFAULT false; \ No newline at end of file diff --git a/cmd/migrate/migrations/000010_add_expiry_to_invitation.down.sql b/cmd/migrate/migrations/000010_add_expiry_to_invitation.down.sql new file mode 100644 index 0000000..833ab93 --- /dev/null +++ b/cmd/migrate/migrations/000010_add_expiry_to_invitation.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE + invitations DROP COLUMN expiry; \ No newline at end of file diff --git a/cmd/migrate/migrations/000010_add_expiry_to_invitation.up.sql b/cmd/migrate/migrations/000010_add_expiry_to_invitation.up.sql new file mode 100644 index 0000000..7dd0270 --- /dev/null +++ b/cmd/migrate/migrations/000010_add_expiry_to_invitation.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE + invitations +ADD COLUMN + expiry TIMESTAMP(0) WITH TIME ZONE NOT NULL; \ No newline at end of file diff --git a/cmd/migrate/migrations/000011_add_roles_table.down.sql b/cmd/migrate/migrations/000011_add_roles_table.down.sql new file mode 100644 index 0000000..a4e0c54 --- /dev/null +++ b/cmd/migrate/migrations/000011_add_roles_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS roles; \ No newline at end of file diff --git a/cmd/migrate/migrations/000011_add_roles_table.up.sql b/cmd/migrate/migrations/000011_add_roles_table.up.sql new file mode 100644 index 0000000..6ee6453 --- /dev/null +++ b/cmd/migrate/migrations/000011_add_roles_table.up.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS roles ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + level INT NOT NULL DEFAULT 1, + description TEXT +); + +INSERT INTO + roles (name, level, description) +VALUES ( +'user', +1, +'User' +); + +INSERT INTO + roles (name, level, description) +VALUES ( +'moderator', +2, +'Moderator' +); + +INSERT INTO + roles (name, level, description) +VALUES ( +'admin', +3, +'Administrator' +); \ No newline at end of file diff --git a/cmd/migrate/migrations/000012_alter_users_with_roles.down.sql b/cmd/migrate/migrations/000012_alter_users_with_roles.down.sql new file mode 100644 index 0000000..acc9f93 --- /dev/null +++ b/cmd/migrate/migrations/000012_alter_users_with_roles.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE +IF EXISTS users DROP COLUMN role_id; \ No newline at end of file diff --git a/cmd/migrate/migrations/000012_alter_users_with_roles.up.sql b/cmd/migrate/migrations/000012_alter_users_with_roles.up.sql new file mode 100644 index 0000000..381c6eb --- /dev/null +++ b/cmd/migrate/migrations/000012_alter_users_with_roles.up.sql @@ -0,0 +1,27 @@ +ALTER TABLE + IF EXISTS users +ADD + COLUMN role_id INT REFERENCES roles(id) DEFAULT 1; + +UPDATE + users +SET + role_id = ( + SELECT + id + FROM + roles + WHERE + name = 'user' + ) +WHERE + role_id IS NULL; + +ALTER TABLE + users +ALTER COLUMN + role_id DROP DEFAULT; + +ALTER TABLE users +ALTER COLUMN role_id +SET NOT NULL; diff --git a/cmd/migrate/seed/main.go b/cmd/migrate/seed/main.go new file mode 100644 index 0000000..4268a35 --- /dev/null +++ b/cmd/migrate/seed/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + + "github.com/FernandoVideira/LK_API_Temp/internal/db" + "github.com/FernandoVideira/LK_API_Temp/internal/env" + "github.com/FernandoVideira/LK_API_Temp/internal/store" +) + +func main() { + addr := env.GetString("DB_ADDR", "postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable") + conn, err := db.New(addr, 3, 3, env.GetDuration("DB_MAX_IDLE_TIME", "5m")) + if err != nil { + log.Fatalf("could not connect to db: %v", err) + } + defer conn.Close() + + store := store.NewStorage(conn) + db.Seed(store, conn) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9a99b1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + db: + image: postgres:16.3 + container_name: postgres-db + environment: + POSTGRES_DB: lk_tmpl + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + redis: + image: redis:6.2-alpine + restart: unless-stopped + container_name: redis + ports: + - "6379:6379" + command: redis-server --save 60 1 --loglevel warning + + redis-commander: + container_name: redis-commander + hostname: redis-commander + image: rediscommander/redis-commander:latest + environment: + - REDIS_HOST=redis + ports: + - "127.0.0.1:8081:8081" + depends_on: + - redis + restart: unless-stopped + +volumes: + db-data: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..1026ceb --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1105 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/authentication/token": { + "post": { + "description": "Create a new token for the user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Create a new token", + "parameters": [ + { + "description": "User Credentials", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.CredentialsPayload" + } + } + ], + "responses": { + "201": { + "description": "Token Created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/authentication/user": { + "post": { + "description": "Register a new User", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Register a new User", + "parameters": [ + { + "description": "User Credentials", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.RegisterUserPayload" + } + } + ], + "responses": { + "201": { + "description": "User Registered", + "schema": { + "$ref": "#/definitions/main.UserWithToken" + } + }, + "400": { + "description": "User payload error", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/health": { + "get": { + "description": "Health Check", + "tags": [ + "ops" + ], + "summary": "Health Check", + "responses": { + "204": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new Post", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Creates a new Post", + "parameters": [ + { + "description": "Post Payload", + "name": "in", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.CreatePostPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Fetches a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletse a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Deletes a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Update a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Post Payload", + "name": "in", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.UpdatePostPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts/{id}/comments": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new Comment", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Creates a new Comment", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Comment" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/activate/{token}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Activate/Register a user by invitation token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Activate/Register a user", + "parameters": [ + { + "type": "string", + "description": "Invitation Token", + "name": "token", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User activated", + "schema": { + "type": "string" + } + }, + "404": { + "description": "User not found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/feed": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches the user feed", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "feed" + ], + "summary": "Fetches the user feed", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Sort", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Tags", + "name": "tags", + "in": "query" + }, + { + "type": "string", + "description": "Search", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Since", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Until", + "name": "until", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.PostWithMetadata" + } + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches a user Profile by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Fetches a user Profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a user Profile by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update a user Profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User Profile", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.updateUserPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/{id}/follow": { + "put": { + "description": "Follows a user by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Follows a user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User followed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "User not found", + "schema": {} + } + } + } + }, + "/users/{id}/password": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update user password with verification\nPassword must:\n- Be at least 8 characters long\n- Contain at least one uppercase letter\n- Contain at least one lowercase letter\n- Contain at least one number\n- Contain at least one special character\n- Be different from the current password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user password", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password Update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.updateUserPasswordPayload" + } + } + ], + "responses": { + "204": { + "description": "Password updated successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "User not found", + "schema": {} + }, + "500": { + "description": "Internal server error", + "schema": {} + } + } + } + }, + "/users/{id}/unfollow": { + "put": { + "description": "Unfollows a user by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Unfollows a user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User unfollowed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "User payload error", + "schema": {} + }, + "404": { + "description": "User not found", + "schema": {} + } + } + } + } + }, + "definitions": { + "main.CreatePostPayload": { + "type": "object", + "required": [ + "content", + "title" + ], + "properties": { + "content": { + "type": "string", + "maxLength": 1000 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.CredentialsPayload": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 3 + } + } + }, + "main.RegisterUserPayload": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "first_name": { + "type": "string", + "maxLength": 100 + }, + "last_name": { + "type": "string", + "maxLength": 100 + }, + "password": { + "type": "string" + }, + "username": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.UpdatePostPayload": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 1000 + }, + "title": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.UserWithToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/store.Role" + }, + "role_id": { + "type": "integer" + }, + "token": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "main.updateUserPasswordPayload": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + } + }, + "main.updateUserPayload": { + "type": "object", + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "first_name": { + "type": "string", + "maxLength": 100 + }, + "last_name": { + "type": "string", + "maxLength": 100 + }, + "username": { + "type": "string", + "maxLength": 100 + } + } + }, + "store.Comment": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "post_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + } + } + }, + "store.Post": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Comment" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "store.PostWithMetadata": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" + }, + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Comment" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "store.Role": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "store.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/store.Role" + }, + "role_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "", + Host: "", + BasePath: "/v1", + Schemes: []string{}, + Title: "LK API Template", + Description: "This is a template for building APIs with Go, Chi, and Swagger.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..545cda2 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1079 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is a template for building APIs with Go, Chi, and Swagger.", + "title": "LK API Template", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "support@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "basePath": "/v1", + "paths": { + "/authentication/token": { + "post": { + "description": "Create a new token for the user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Create a new token", + "parameters": [ + { + "description": "User Credentials", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.CredentialsPayload" + } + } + ], + "responses": { + "201": { + "description": "Token Created", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/authentication/user": { + "post": { + "description": "Register a new User", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Register a new User", + "parameters": [ + { + "description": "User Credentials", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.RegisterUserPayload" + } + } + ], + "responses": { + "201": { + "description": "User Registered", + "schema": { + "$ref": "#/definitions/main.UserWithToken" + } + }, + "400": { + "description": "User payload error", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/health": { + "get": { + "description": "Health Check", + "tags": [ + "ops" + ], + "summary": "Health Check", + "responses": { + "204": { + "description": "ok", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new Post", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Creates a new Post", + "parameters": [ + { + "description": "Post Payload", + "name": "in", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.CreatePostPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Fetches a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletse a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Deletes a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a Post by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Update a Post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Post Payload", + "name": "in", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.UpdatePostPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Post" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/posts/{id}/comments": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new Comment", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Creates a new Comment", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.Comment" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/activate/{token}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Activate/Register a user by invitation token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Activate/Register a user", + "parameters": [ + { + "type": "string", + "description": "Invitation Token", + "name": "token", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User activated", + "schema": { + "type": "string" + } + }, + "404": { + "description": "User not found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/feed": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches the user feed", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "feed" + ], + "summary": "Fetches the user feed", + "parameters": [ + { + "type": "integer", + "description": "Limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Sort", + "name": "sort", + "in": "query" + }, + { + "type": "string", + "description": "Tags", + "name": "tags", + "in": "query" + }, + { + "type": "string", + "description": "Search", + "name": "search", + "in": "query" + }, + { + "type": "string", + "description": "Since", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "Until", + "name": "until", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/store.PostWithMetadata" + } + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Fetches a user Profile by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Fetches a user Profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + }, + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update a user Profile by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update a user Profile", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User Profile", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.updateUserPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/store.User" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/users/{id}/follow": { + "put": { + "description": "Follows a user by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Follows a user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User followed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "User not found", + "schema": {} + } + } + } + }, + "/users/{id}/password": { + "patch": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Update user password with verification\nPassword must:\n- Be at least 8 characters long\n- Contain at least one uppercase letter\n- Contain at least one lowercase letter\n- Contain at least one number\n- Contain at least one special character\n- Be different from the current password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update user password", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password Update", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.updateUserPasswordPayload" + } + } + ], + "responses": { + "204": { + "description": "Password updated successfully", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "User not found", + "schema": {} + }, + "500": { + "description": "Internal server error", + "schema": {} + } + } + } + }, + "/users/{id}/unfollow": { + "put": { + "description": "Unfollows a user by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Unfollows a user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "User unfollowed", + "schema": { + "type": "string" + } + }, + "400": { + "description": "User payload error", + "schema": {} + }, + "404": { + "description": "User not found", + "schema": {} + } + } + } + } + }, + "definitions": { + "main.CreatePostPayload": { + "type": "object", + "required": [ + "content", + "title" + ], + "properties": { + "content": { + "type": "string", + "maxLength": 1000 + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.CredentialsPayload": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 3 + } + } + }, + "main.RegisterUserPayload": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "first_name": { + "type": "string", + "maxLength": 100 + }, + "last_name": { + "type": "string", + "maxLength": 100 + }, + "password": { + "type": "string" + }, + "username": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.UpdatePostPayload": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 1000 + }, + "title": { + "type": "string", + "maxLength": 100 + } + } + }, + "main.UserWithToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/store.Role" + }, + "role_id": { + "type": "integer" + }, + "token": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "main.updateUserPasswordPayload": { + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + } + }, + "main.updateUserPayload": { + "type": "object", + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "first_name": { + "type": "string", + "maxLength": 100 + }, + "last_name": { + "type": "string", + "maxLength": 100 + }, + "username": { + "type": "string", + "maxLength": 100 + } + } + }, + "store.Comment": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "post_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + } + } + }, + "store.Post": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Comment" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "store.PostWithMetadata": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" + }, + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/store.Comment" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/store.User" + }, + "user_id": { + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "store.Role": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "store.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "last_name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/store.Role" + }, + "role_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..ac126c4 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,725 @@ +basePath: /v1 +definitions: + main.CreatePostPayload: + properties: + content: + maxLength: 1000 + type: string + tags: + items: + type: string + type: array + title: + maxLength: 100 + type: string + required: + - content + - title + type: object + main.CredentialsPayload: + properties: + email: + maxLength: 255 + type: string + password: + maxLength: 72 + minLength: 3 + type: string + required: + - email + - password + type: object + main.RegisterUserPayload: + properties: + email: + maxLength: 255 + type: string + first_name: + maxLength: 100 + type: string + last_name: + maxLength: 100 + type: string + password: + type: string + username: + maxLength: 100 + type: string + required: + - email + - first_name + - last_name + - password + - username + type: object + main.UpdatePostPayload: + properties: + content: + maxLength: 1000 + type: string + title: + maxLength: 100 + type: string + type: object + main.UserWithToken: + properties: + created_at: + type: string + email: + type: string + first_name: + type: string + id: + type: integer + is_active: + type: boolean + last_name: + type: string + role: + $ref: '#/definitions/store.Role' + role_id: + type: integer + token: + type: string + updated_at: + type: string + username: + type: string + type: object + main.updateUserPasswordPayload: + properties: + current_password: + type: string + new_password: + type: string + required: + - current_password + - new_password + type: object + main.updateUserPayload: + properties: + email: + maxLength: 255 + type: string + first_name: + maxLength: 100 + type: string + last_name: + maxLength: 100 + type: string + username: + maxLength: 100 + type: string + type: object + store.Comment: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + post_id: + type: integer + updated_at: + type: string + user: + $ref: '#/definitions/store.User' + user_id: + type: integer + type: object + store.Post: + properties: + comments: + items: + $ref: '#/definitions/store.Comment' + type: array + content: + type: string + created_at: + type: string + id: + type: integer + tags: + items: + type: string + type: array + title: + type: string + updated_at: + type: string + user: + $ref: '#/definitions/store.User' + user_id: + type: integer + version: + type: integer + type: object + store.PostWithMetadata: + properties: + comment_count: + type: integer + comments: + items: + $ref: '#/definitions/store.Comment' + type: array + content: + type: string + created_at: + type: string + id: + type: integer + tags: + items: + type: string + type: array + title: + type: string + updated_at: + type: string + user: + $ref: '#/definitions/store.User' + user_id: + type: integer + version: + type: integer + type: object + store.Role: + properties: + description: + type: string + id: + type: integer + level: + type: integer + name: + type: string + type: object + store.User: + properties: + created_at: + type: string + email: + type: string + first_name: + type: string + id: + type: integer + is_active: + type: boolean + last_name: + type: string + role: + $ref: '#/definitions/store.Role' + role_id: + type: integer + updated_at: + type: string + username: + type: string + type: object +info: + contact: + email: support@swagger.io + name: API Support + url: http://www.swagger.io/support + description: This is a template for building APIs with Go, Chi, and Swagger. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: LK API Template +paths: + /authentication/token: + post: + consumes: + - application/json + description: Create a new token for the user + parameters: + - description: User Credentials + in: body + name: payload + required: true + schema: + $ref: '#/definitions/main.CredentialsPayload' + produces: + - application/json + responses: + "201": + description: Token Created + schema: + type: string + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Create a new token + tags: + - authentication + /authentication/user: + post: + consumes: + - application/json + description: Register a new User + parameters: + - description: User Credentials + in: body + name: payload + required: true + schema: + $ref: '#/definitions/main.RegisterUserPayload' + produces: + - application/json + responses: + "201": + description: User Registered + schema: + $ref: '#/definitions/main.UserWithToken' + "400": + description: User payload error + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Register a new User + tags: + - authentication + /health: + get: + description: Health Check + responses: + "204": + description: ok + schema: + type: string + "500": + description: Internal Server Error + schema: {} + summary: Health Check + tags: + - ops + /posts: + post: + consumes: + - application/json + description: Creates a new Post + parameters: + - description: Post Payload + in: body + name: in + required: true + schema: + $ref: '#/definitions/main.CreatePostPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.Post' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Creates a new Post + tags: + - posts + /posts/{id}: + delete: + consumes: + - application/json + description: Deletse a Post by ID + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + schema: + type: string + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Deletes a Post + tags: + - posts + get: + consumes: + - application/json + description: Fetches a Post by ID + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.Post' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Fetches a Post + tags: + - posts + patch: + consumes: + - application/json + description: Update a Post by ID + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + - description: Post Payload + in: body + name: in + required: true + schema: + $ref: '#/definitions/main.UpdatePostPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.Post' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Update a Post + tags: + - posts + /posts/{id}/comments: + post: + consumes: + - application/json + description: Creates a new Comment + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.Comment' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Creates a new Comment + tags: + - comments + /users/{id}: + get: + consumes: + - application/json + description: Fetches a user Profile by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.User' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Fetches a user Profile + tags: + - users + patch: + consumes: + - application/json + description: Update a user Profile by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + - description: User Profile + in: body + name: payload + required: true + schema: + $ref: '#/definitions/main.updateUserPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/store.User' + "400": + description: Bad Request + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Update a user Profile + tags: + - users + /users/{id}/follow: + put: + consumes: + - application/json + description: Follows a user by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: User followed + schema: + type: string + "400": + description: User not found + schema: {} + summary: Follows a user + tags: + - users + /users/{id}/password: + patch: + consumes: + - application/json + description: |- + Update user password with verification + Password must: + - Be at least 8 characters long + - Contain at least one uppercase letter + - Contain at least one lowercase letter + - Contain at least one number + - Contain at least one special character + - Be different from the current password + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + - description: Password Update + in: body + name: payload + required: true + schema: + $ref: '#/definitions/main.updateUserPasswordPayload' + produces: + - application/json + responses: + "204": + description: Password updated successfully + schema: + type: string + "400": + description: Invalid request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: User not found + schema: {} + "500": + description: Internal server error + schema: {} + security: + - ApiKeyAuth: [] + summary: Update user password + tags: + - users + /users/{id}/unfollow: + put: + consumes: + - application/json + description: Unfollows a user by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: User unfollowed + schema: + type: string + "400": + description: User payload error + schema: {} + "404": + description: User not found + schema: {} + summary: Unfollows a user + tags: + - users + /users/activate/{token}: + put: + consumes: + - application/json + description: Activate/Register a user by invitation token + parameters: + - description: Invitation Token + in: path + name: token + required: true + type: string + produces: + - application/json + responses: + "204": + description: User activated + schema: + type: string + "404": + description: User not found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Activate/Register a user + tags: + - users + /users/feed: + get: + consumes: + - application/json + description: Fetches the user feed + parameters: + - description: Limit + in: query + name: limit + type: integer + - description: Offset + in: query + name: offset + type: integer + - description: Sort + in: query + name: sort + type: string + - description: Tags + in: query + name: tags + type: string + - description: Search + in: query + name: search + type: string + - description: Since + in: query + name: since + type: string + - description: Until + in: query + name: until + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/store.PostWithMetadata' + type: array + "400": + description: Bad Request + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - ApiKeyAuth: [] + summary: Fetches the user feed + tags: + - feed +securityDefinitions: + ApiKeyAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1e0fa59 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module github.com/FernandoVideira/LK_API_Temp + +go 1.23.4 + +require ( + github.com/go-chi/chi/v5 v5.2.0 + github.com/go-chi/cors v1.2.1 + github.com/go-playground/validator/v10 v10.24.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/lib/pq v1.10.9 + github.com/sendgrid/sendgrid-go v3.16.0+incompatible + github.com/stretchr/testify v1.10.0 + github.com/swaggo/http-swagger/v2 v2.0.2 + github.com/swaggo/swag v1.16.4 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.32.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/tools v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..59d7fb3 --- /dev/null +++ b/go.sum @@ -0,0 +1,103 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= +github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg= +github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..1cc8098 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,8 @@ +package auth + +import "github.com/golang-jwt/jwt/v5" + +type Authenticator interface { + GenerateToken(jwt.Claims) (string, error) + ValidateToken(string) (*jwt.Token, error) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..5734375 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,47 @@ +package auth + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTAuthenticator struct { + secret string + aud string + iss string +} + +func NewJWTAuthenticator(secret, aud, iss string) *JWTAuthenticator { + return &JWTAuthenticator{ + secret: secret, + aud: aud, + iss: iss, + } +} + +func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenString, err := token.SignedString([]byte(j.secret)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) { + return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + + return []byte(j.secret), nil + }, + jwt.WithExpirationRequired(), + jwt.WithAudience(j.aud), + jwt.WithIssuer(j.iss), + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}), + ) +} diff --git a/internal/auth/mocks.go b/internal/auth/mocks.go new file mode 100644 index 0000000..9ad4980 --- /dev/null +++ b/internal/auth/mocks.go @@ -0,0 +1,31 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type TestAuthenticator struct { +} + +const testToken = "test-token" + +var testClaims = jwt.MapClaims{ + "aud": "test-audience", + "iss": "test-issuer", + "sub": 1, + "exp": time.Now().Add(time.Hour * 24).Unix(), +} + +func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims) + + return token.SignedString([]byte(testToken)) +} + +func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) { + return jwt.Parse(token, func(token *jwt.Token) (any, error) { + return []byte(testToken), nil + }) +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..eb64186 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,27 @@ +package db + +import ( + "context" + "database/sql" + "time" +) + +func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) { + db, err := sql.Open("postgres", addr) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(maxOpenConns) + db.SetMaxIdleConns(maxIdleConns) + db.SetConnMaxIdleTime(maxIdleTime) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/db/seed.go b/internal/db/seed.go new file mode 100644 index 0000000..5adfd73 --- /dev/null +++ b/internal/db/seed.go @@ -0,0 +1,186 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "log" + "math/rand" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" +) + +var first_names = []string{ + "Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy", + "Mallory", "Nathan", "Olivia", "Peggy", "Quinn", "Rachel", "Sam", "Trent", "Ursula", "Victor", + "Wendy", "Xander", "Yvonne", "Zach", "Amy", "Brian", "Carl", "Diana", "Edward", "Fiona", + "George", "Hannah", "Ian", "Jessica", "Kevin", "Lisa", "Michael", "Nina", "Oscar", "Paula", + "Quentin", "Rebecca", "Steve", "Tina", "Uma", "Vincent", "Willow", "Xenia", "Yuri", "Zoe", +} + +var last_names = []string{ + "Smith", "Johnson", "Williams", "Jones", "Brown", "Davis", "Miller", "Wilson", "Moore", "Taylor", + "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Garcia", "Martinez", "Robinson", + "Clark", "Rodriguez", "Lewis", "Lee", "Walker", "Hall", "Allen", "Young", "Hernandez", "King", + "Wright", "Lopez", "Hill", "Scott", "Green", "Adams", "Baker", "Gonzalez", "Nelson", "Carter", + "Mitchell", "Perez", "Roberts", "Turner", "Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins", +} + +var usernames = []string{ + "alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy", + "mallory", "nathan", "olivia", "peggy", "quinn", "rachel", "sam", "trent", "ursula", "victor", + "wendy", "xander", "yvonne", "zach", "amy", "brian", "carl", "diana", "edward", "fiona", + "george", "hannah", "ian", "jessica", "kevin", "lisa", "michael", "nina", "oscar", "paula", + "quentin", "rebecca", "steve", "tina", "uma", "vincent", "willow", "xenia", "yuri", "zoe", +} + +var titles = []string{ + "How to Learn Go in 10 Days", + "Understanding Concurrency in Go", + "Building REST APIs with Go", + "Mastering Go: Tips and Tricks", + "Go vs. Python: A Comparison", + "Error Handling in Go", + "Go Routines and Channels Explained", + "Building a Web Server in Go", + "Go for Beginners: A Comprehensive Guide", + "Advanced Go Programming Techniques", + "Testing in Go: Best Practices", + "Go Modules: Managing Dependencies", + "Introduction to Go Interfaces", + "Go Data Structures and Algorithms", + "Building CLI Applications with Go", + "Go Memory Management", + "Go Design Patterns", + "Deploying Go Applications", + "Go and Microservices", + "Profiling and Optimizing Go Code", +} + +var contents = []string{ + "Learn the basics of Go programming in just 10 days with this comprehensive guide.", + "Understand the intricacies of concurrency in Go and how to effectively manage goroutines.", + "Step-by-step guide to building RESTful APIs using the Go programming language.", + "Discover tips and tricks to master Go programming and write efficient code.", + "Compare the features and performance of Go and Python in various scenarios.", + "Learn how to handle errors gracefully in Go and write robust applications.", + "An in-depth explanation of goroutines and channels in Go for concurrent programming.", + "Build a simple yet powerful web server using Go and the net/http package.", + "A comprehensive guide for beginners to learn Go programming from scratch.", + "Explore advanced programming techniques in Go to write high-performance applications.", + "Best practices for writing and running tests in Go to ensure code quality.", + "Manage dependencies in your Go projects using Go Modules effectively.", + "An introduction to interfaces in Go and how to use them for polymorphism.", + "Learn about various data structures and algorithms implemented in Go.", + "Build command-line applications in Go with ease using the flag package.", + "Understand Go's memory management and how to optimize memory usage.", + "Explore common design patterns in Go to write maintainable and scalable code.", + "Learn how to deploy Go applications to various environments.", + "Discover how to build microservices architecture using Go.", + "Techniques for profiling and optimizing the performance of Go code.", +} + +var tags = []string{ + "golang", "programming", "tutorial", "webdev", "backend", "frontend", "api", "concurrency", "microservices", "testing", + "designpatterns", "cli", "memory", "optimization", "deployment", "databases", "security", "networking", "algorithms", "datastructures", +} + +var comments = []string{ + "Great post! Thanks for sharing.", + "Very informative. I learned a lot.", + "I disagree with some points, but overall a good read.", + "Can you provide more details on this topic?", + "Excellent writing! Keep it up.", + "This helped me understand Go better. Thanks!", + "Interesting perspective. I never thought about it that way.", + "Could you share the source code?", + "Looking forward to your next post.", + "Thanks for the insights. Very helpful.", + "Well explained. I appreciate the examples.", + "I have a question about concurrency in Go.", + "Can you recommend more resources on this topic?", + "Great tips! I'll definitely try them out.", + "This post is a bit confusing. Can you clarify?", + "Thanks for the tutorial. It was easy to follow.", + "I found a typo in the code snippet.", + "Can you write about error handling in Go?", + "Your posts are always so detailed and helpful.", + "I tried this and it worked perfectly. Thanks!", +} + +func Seed(store store.Storage, db *sql.DB) { + ctx := context.Background() + + users := generateUsers(100) + tx, _ := db.BeginTx(ctx, nil) + + for _, u := range users { + if err := store.Users.Create(ctx, tx, u); err != nil { + tx.Rollback() + log.Println("failed to create user:", err) + } + } + + tx.Commit() + + posts := generatePosts(200, users) + for _, p := range posts { + if err := store.Posts.Create(ctx, p); err != nil { + log.Println("failed to create post:", err) + } + } + + comments := generateComments(500, users, posts) + for _, c := range comments { + if err := store.Comments.Create(ctx, c); err != nil { + log.Println("failed to create comment:", err) + } + } + + log.Println("seeded database with sample data") +} + +func generateUsers(n int) []*store.User { + users := make([]*store.User, n) + for i := 0; i < n; i++ { + users[i] = &store.User{ + FirstName: first_names[i%len(first_names)], + LastName: last_names[i%len(last_names)], + Username: usernames[i%len(usernames)] + fmt.Sprintf("%d", i), + Email: usernames[i%len(usernames)] + fmt.Sprintf("%d", i) + "@example.com", + Role: store.Role{ + Name: "user", + }, + } + } + return users +} + +func generatePosts(n int, users []*store.User) []*store.Post { + + posts := make([]*store.Post, n) + for i := 0; i < n; i++ { + user := users[rand.Intn(len(users))] + posts[i] = &store.Post{ + UserID: user.ID, + Title: titles[rand.Intn(len(titles))], + Content: contents[rand.Intn(len(contents))], + Tags: []string{tags[rand.Intn(len(tags))], tags[rand.Intn(len(tags))]}, + } + } + return posts +} + +func generateComments(n int, users []*store.User, posts []*store.Post) []*store.Comment { + post_comments := make([]*store.Comment, n) + for i := 0; i < n; i++ { + user := users[rand.Intn(len(users))] + post := posts[rand.Intn(len(posts))] + post_comments[i] = &store.Comment{ + PostID: post.ID, + UserID: user.ID, + Content: comments[rand.Intn(len(comments))], + } + } + return post_comments +} diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..d6a4058 --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,64 @@ +package env + +import ( + "os" + "strconv" + "strings" + "time" +) + +func GetString(key, fallback string) string { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + return val +} + +func GetInt(key string, fallback int) int { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + valAsInt, err := strconv.Atoi(val) + if err != nil { + return fallback + } + return valAsInt +} + +func GetDuration(key string, fallback string) time.Duration { + parsedFallback, _ := time.ParseDuration(fallback) + + val, ok := os.LookupEnv(key) + if !ok { + return parsedFallback + } + + parsedVal, err := time.ParseDuration(val) + if err != nil { + return parsedFallback + } + + return parsedVal +} + +func GetBool(key string, fallback bool) bool { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + valAsBool, err := strconv.ParseBool(val) + if err != nil { + return fallback + } + return valAsBool +} + +func GetList(key string, fallback []string) []string { + val, ok := os.LookupEnv(key) + if !ok { + return fallback + } + return strings.Split(val, ",") +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..efed29b --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,16 @@ +package mailer + +import "embed" + +const ( + FromName = "LK API" + maxRetries = 3 + UserWelcomeTemplate = "user_activation.tmpl" +) + +//go:embed "templates" +var FS embed.FS + +type Client interface { + Send(templateFile, username, email string, data any, isDevEnv bool) (int, error) +} diff --git a/internal/mailer/sendgrid.go b/internal/mailer/sendgrid.go new file mode 100644 index 0000000..bf34839 --- /dev/null +++ b/internal/mailer/sendgrid.go @@ -0,0 +1,67 @@ +package mailer + +import ( + "bytes" + "fmt" + "text/template" + "time" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type SendGridMailer struct { + fromEmail string + apiKey string + client *sendgrid.Client +} + +func NewSendGrid(apiKey, fromEmail string) *SendGridMailer { + client := sendgrid.NewSendClient(apiKey) + return &SendGridMailer{ + fromEmail: fromEmail, + apiKey: apiKey, + client: client, + } +} + +func (s *SendGridMailer) Send(templateFile, username, email string, data any, isDevEnv bool) (int, error) { + from := mail.NewEmail(FromName, s.fromEmail) + to := mail.NewEmail(username, email) + + tmpl, err := template.ParseFS(FS, "templates/"+templateFile) + if err != nil { + return -1, err + } + + // Template Parsing and sending + subject := new(bytes.Buffer) + if err := tmpl.ExecuteTemplate(subject, "subject", data); err != nil { + return -1, err + } + + body := new(bytes.Buffer) + if err := tmpl.ExecuteTemplate(body, "body", data); err != nil { + return -1, err + } + + message := mail.NewSingleEmail(from, subject.String(), to, "", body.String()) + + message.SetMailSettings(&mail.MailSettings{ + SandboxMode: &mail.Setting{ + Enable: &isDevEnv, + }, + }) + + var retryErr error + for i := 0; i < maxRetries; i++ { + resp, retryErr := s.client.Send(message) + if retryErr != nil { + //* Exponential backoff + time.Sleep(time.Duration(i+1) * time.Second) + continue + } + return resp.StatusCode, nil + } + return -1, fmt.Errorf("failed to send the email after %d attempts, error: %v", maxRetries, retryErr) +} diff --git a/internal/mailer/templates/user_activation.tmpl b/internal/mailer/templates/user_activation.tmpl new file mode 100644 index 0000000..d46dd65 --- /dev/null +++ b/internal/mailer/templates/user_activation.tmpl @@ -0,0 +1,22 @@ +{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}} + +{{define "body"}} + + +
+ + + +Hi {{.Username}},
+Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!
+Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:
+ +If you want to activate your account manually copy and paste the code from the link above
+If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.
+ +Thanks,
+The {{.ServiceName}} Team
+ + + +{{end}} \ No newline at end of file diff --git a/internal/ratelimiter/fixed_window.go b/internal/ratelimiter/fixed_window.go new file mode 100644 index 0000000..78bd743 --- /dev/null +++ b/internal/ratelimiter/fixed_window.go @@ -0,0 +1,51 @@ +package ratelimiter + +import ( + "log" + "sync" + "time" +) + +type FixedWindowLimiter struct { + sync.RWMutex + clients map[string]int + limit int + window time.Duration +} + +func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter { + return &FixedWindowLimiter{ + clients: make(map[string]int), + limit: limit, + window: window, + } +} + +func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) { + rl.RLock() + count, exists := rl.clients[ip] + rl.RUnlock() + + log.Println(count, exists) + + if !exists || count < rl.limit { + rl.Lock() + if !exists { + go rl.resetCount(ip) + } + + rl.clients[ip]++ + rl.Unlock() + return true, 0 + } + + return false, rl.window +} + +func (rl *FixedWindowLimiter) resetCount(ip string) { + time.Sleep(rl.window) + + rl.Lock() + delete(rl.clients, ip) + rl.Unlock() +} diff --git a/internal/ratelimiter/ratelimirer.go b/internal/ratelimiter/ratelimirer.go new file mode 100644 index 0000000..6d6084e --- /dev/null +++ b/internal/ratelimiter/ratelimirer.go @@ -0,0 +1,15 @@ +package ratelimiter + +import "time" + +//! Use Redis for rate limiting when in multiple instances + +type Limiter interface { + Allow(ip string) (bool, time.Duration) +} + +type Config struct { + RequestsPerTimeFrame int + TimeFrame time.Duration + Enabled bool +} diff --git a/internal/store/cache/mocks.go b/internal/store/cache/mocks.go new file mode 100644 index 0000000..886e58f --- /dev/null +++ b/internal/store/cache/mocks.go @@ -0,0 +1,33 @@ +package cache + +import ( + "context" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/stretchr/testify/mock" +) + +func NewMockCache() Storage { + return Storage{ + Users: &MockUserStore{}, + } +} + +type MockUserStore struct { + mock.Mock +} + +func (m *MockUserStore) Get(ctx context.Context, id int64) (*store.User, error) { + args := m.Called(id) + return nil, args.Error(1) +} + +func (m *MockUserStore) Set(ctx context.Context, user *store.User) error { + args := m.Called(user) + return args.Error(0) +} + +func (m *MockUserStore) Delete(ctx context.Context, id int64) error { + args := m.Called(id) + return args.Error(0) +} diff --git a/internal/store/cache/redis.go b/internal/store/cache/redis.go new file mode 100644 index 0000000..8b35bce --- /dev/null +++ b/internal/store/cache/redis.go @@ -0,0 +1,11 @@ +package cache + +import "github.com/go-redis/redis/v8" + +func NewRedisClient(addr, pw string, db int) *redis.Client { + return redis.NewClient(&redis.Options{ + Addr: addr, + Password: pw, + DB: db, + }) +} diff --git a/internal/store/cache/storage.go b/internal/store/cache/storage.go new file mode 100644 index 0000000..20fd8f8 --- /dev/null +++ b/internal/store/cache/storage.go @@ -0,0 +1,22 @@ +package cache + +import ( + "context" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/go-redis/redis/v8" +) + +type Storage struct { + Users interface { + Get(context.Context, int64) (*store.User, error) + Set(context.Context, *store.User) error + Delete(context.Context, int64) error + } +} + +func NewRedisStorage(rdb *redis.Client) Storage { + return Storage{ + Users: &UserStore{rdb: rdb}, + } +} diff --git a/internal/store/cache/users.go b/internal/store/cache/users.go new file mode 100644 index 0000000..ba57a51 --- /dev/null +++ b/internal/store/cache/users.go @@ -0,0 +1,60 @@ +package cache + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/FernandoVideira/LK_API_Temp/internal/store" + "github.com/go-redis/redis/v8" +) + +type UserStore struct { + rdb *redis.Client +} + +const UserExpTime = time.Hour * 24 + +func (s *UserStore) Get(ctx context.Context, id int64) (*store.User, error) { + cacheKey := fmt.Sprintf("user-%d", id) + + data, err := s.rdb.Get(ctx, cacheKey).Result() + if err == redis.Nil { + return nil, nil + } else if err != nil { + return nil, err + } + + var user store.User + if data != "" { + err := json.Unmarshal([]byte(data), &user) + if err != nil { + return nil, err + } + } + + return &user, nil +} + +func (s *UserStore) Set(ctx context.Context, user *store.User) error { + + if user.ID == 0 { + return fmt.Errorf("user ID is required") + } + + cacheKey := fmt.Sprintf("user-%d", user.ID) + + json, err := json.Marshal(user) + if err != nil { + return err + } + + return s.rdb.SetEX(ctx, cacheKey, json, UserExpTime).Err() +} + +func (s *UserStore) Delete(ctx context.Context, id int64) error { + cacheKey := fmt.Sprintf("user-%d", id) + + return s.rdb.Del(ctx, cacheKey).Err() +} diff --git a/internal/store/comments.go b/internal/store/comments.go new file mode 100644 index 0000000..7f9444c --- /dev/null +++ b/internal/store/comments.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "database/sql" +) + +type Comment struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + UserID int64 `json:"user_id"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + User User `json:"user"` +} + +type CommentStore struct { + db *sql.DB +} + +func (s *CommentStore) Create(ctx context.Context, c *Comment) error { + query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at;` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + err := s.db.QueryRowContext(ctx, query, c.PostID, c.UserID, c.Content).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return err + } + + return nil +} + +func (s *CommentStore) GetByPostID(ctx context.Context, postID int64) ([]Comment, error) { + query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c + JOIN users on c.user_id = users.id + WHERE c.post_id = $1 + ORDER BY c.created_at DESC; + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + rows, err := s.db.QueryContext(ctx, query, postID) + if err != nil { + return nil, err + } + defer rows.Close() + + comments := []Comment{} + for rows.Next() { + var comment Comment + comment.User = User{} + err := rows.Scan( + &comment.ID, + &comment.PostID, + &comment.UserID, + &comment.Content, + &comment.CreatedAt, + &comment.User.Username, + &comment.User.ID, + ) + if err != nil { + return nil, err + } + comments = append(comments, comment) + } + + return comments, nil +} diff --git a/internal/store/follower.go b/internal/store/follower.go new file mode 100644 index 0000000..e182582 --- /dev/null +++ b/internal/store/follower.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/lib/pq" +) + +type Follower struct { + ID int64 `json:"id"` + FolloweeID int64 `json:"followee_id"` + CreatedAt string `json:"created_at"` +} + +type FollowerStore struct { + db *sql.DB +} + +func (s *FollowerStore) Follow(ctx context.Context, followeeID, followerID int64) error { + query := `INSERT INTO followers (user_id, follower_id) VALUES ($1, $2)` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := s.db.ExecContext(ctx, query, followerID, followeeID) + if err != nil { + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return ErrConflict + } + } + + return nil +} + +func (s *FollowerStore) Unfollow(ctx context.Context, followeeID, followerID int64) error { + query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := s.db.ExecContext(ctx, query, followerID, followeeID) + if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" { + return ErrNotFound + } + + return nil +} diff --git a/internal/store/mocks.go b/internal/store/mocks.go new file mode 100644 index 0000000..d1818ac --- /dev/null +++ b/internal/store/mocks.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "database/sql" + "time" + + "github.com/stretchr/testify/mock" +) + +func NewMockStorage() Storage { + return Storage{ + Users: &MockUserStore{}, + } +} + +type MockUserStore struct { + mock.Mock +} + +func (m *MockUserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error { + args := m.Called(tx, user) + return args.Error(0) +} + +func (m *MockUserStore) Activate(ctx context.Context, token string) error { + args := m.Called(token) + return args.Error(0) +} + +func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) { + args := m.Called(id) + return nil, args.Error(1) +} + +func (m *MockUserStore) GetByEmail(ctx context.Context, email string) (*User, error) { + args := m.Called(email) + return nil, args.Error(1) +} + +func (m *MockUserStore) Update(ctx context.Context, user *User) error { + args := m.Called(user) + return args.Error(0) +} + +func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token string, expiresAt time.Duration) error { + args := m.Called(user, token, expiresAt) + return args.Error(0) +} + +func (m *MockUserStore) Delete(ctx context.Context, id int64) error { + args := m.Called(id) + return args.Error(0) +} + +func (m *MockUserStore) DeleteUser(ctx context.Context, id int64) error { + args := m.Called(id) + return args.Error(0) +} diff --git a/internal/store/pagination.go b/internal/store/pagination.go new file mode 100644 index 0000000..7539613 --- /dev/null +++ b/internal/store/pagination.go @@ -0,0 +1,76 @@ +package store + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +type PaginatedFeedQuery struct { + Limit int `json:"limit" validate:"gte=1,lte=20"` + Offset int `json:"offset" validate:"gte=0"` + Sort string `json:"sort" validate:"oneof=asc desc"` + Tags []string `json:"tags" validate:"max=5"` + Search string `json:"search" validate:"max=100"` + Since string `json:"since"` + Until string `json:"until"` +} + +func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) { + qs := r.URL.Query() + + limit := qs.Get("limit") + if limit != "" { + l, err := strconv.Atoi(limit) + if err != nil { + return fq, err + } + fq.Limit = l + } + + offset := qs.Get("offset") + if offset != "" { + o, err := strconv.Atoi(offset) + if err != nil { + return PaginatedFeedQuery{}, err + } + fq.Offset = o + } + + sort := qs.Get("sort") + if sort != "" { + fq.Sort = sort + } + + tags := qs.Get("tags") + if tags != "" { + fq.Tags = strings.Split(tags, ",") + } + + search := qs.Get("search") + if search != "" { + fq.Search = search + } + + since := qs.Get("since") + if since != "" { + fq.Since = parseTime(since) + } + + until := qs.Get("until") + if until != "" { + fq.Until = parseTime(until) + } + + return fq, nil +} + +func parseTime(s string) string { + t, err := time.Parse(time.DateTime, s) + if err != nil { + return "" + } + return t.Format(time.DateTime) + +} diff --git a/internal/store/posts.go b/internal/store/posts.go new file mode 100644 index 0000000..086ea84 --- /dev/null +++ b/internal/store/posts.go @@ -0,0 +1,205 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/lib/pq" +) + +type Post struct { + ID int64 `json:"id"` + Content string `json:"content"` + Title string `json:"title"` + UserID int64 `json:"user_id"` + Tags []string `json:"tags"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Version int `json:"version"` + Comments []Comment `json:"comments"` + User User `json:"user"` +} + +type PostWithMetadata struct { + Post + CommentCount int `json:"comment_count"` +} + +type PostStore struct { + db *sql.DB +} + +func (s *PostStore) Create(ctx context.Context, post *Post) error { + query := ` + INSERT INTO posts (content, title, user_id, tags) + VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + err := s.db.QueryRowContext( + ctx, + query, + post.Content, + post.Title, + post.UserID, + pq.Array(post.Tags), + ).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt) + if err != nil { + return err + } + + return nil +} + +func (s *PostStore) GetByID(ctx context.Context, id int64) (*Post, error) { + query := ` + SELECT id, content, title, user_id, tags, created_at, updated_at, version + FROM posts + WHERE id = $1 + ` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + var post Post + err := s.db.QueryRowContext(ctx, query, id).Scan( + &post.ID, + &post.Content, + &post.Title, + &post.UserID, + pq.Array(&post.Tags), + &post.CreatedAt, + &post.UpdatedAt, + &post.Version, + ) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrNotFound + default: + return nil, err + } + } + + return &post, nil +} + +func (s *PostStore) GetUserFeed(ctx context.Context, userID int64, fq PaginatedFeedQuery) ([]PostWithMetadata, error) { + query := ` + SELECT + p.id, p.user_id, p.title, p.content, p.created_at, p.updated_at, p.version, p.tags, + u.username, + COUNT(c.id) AS comments_count + FROM posts p + LEFT JOIN comments c on c.post_id = p.id + LEFT JOIN users u ON p.user_id = u.id + JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1 + WHERE + f.user_id = $1 AND + (p.title ILIKE '%' || $4 || '%' OR p.content ILIKE '%' || $4 || '%') AND + (p.tags @> $5 OR $5 = '{}') + GROUP BY p.id, u.username + ORDER BY p.created_at ` + fq.Sort + ` + LIMIT $2 OFFSET $3 + ` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + rows, err := s.db.QueryContext(ctx, query, userID, fq.Limit, fq.Offset, fq.Search, pq.Array(fq.Tags)) + if err != nil { + return nil, err + } + defer rows.Close() + + var feed []PostWithMetadata + for rows.Next() { + var p PostWithMetadata + err := rows.Scan( + &p.ID, + &p.UserID, + &p.Title, + &p.Content, + &p.CreatedAt, + &p.UpdatedAt, + &p.Version, + pq.Array(&p.Tags), + &p.User.Username, + &p.CommentCount, + ) + if err != nil { + return nil, err + } + + fmt.Println(p) + + feed = append(feed, p) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return feed, nil + +} + +func (s *PostStore) Update(ctx context.Context, post *Post) error { + query := ` + UPDATE posts + SET content = $1, title = $2, updated_at = NOW(), version = version + 1 + WHERE id = $3 AND version = $4 + RETURNING version + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + err := s.db.QueryRowContext( + ctx, + query, + post.Content, + post.Title, + post.ID, + post.Version, + ).Scan(&post.Version) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ErrNotFound + default: + return err + } + } + + return nil +} + +func (s *PostStore) Delete(ctx context.Context, id int64) error { + query := ` + DELETE FROM posts + WHERE id = $1 + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + res, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + rowsAffected, err := res.RowsAffected() + if err != nil { + return ErrNotFound + } + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil + +} diff --git a/internal/store/roles.go b/internal/store/roles.go new file mode 100644 index 0000000..ef297f9 --- /dev/null +++ b/internal/store/roles.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + "database/sql" +) + +type Role struct { + ID int64 `json:"id"` + Name string `json:"name"` + Level int64 `json:"level"` + Description string `json:"description"` +} + +type RoleStore struct { + db *sql.DB +} + +func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) { + query := ` + SELECT id, name, level, description + FROM roles + WHERE name = $1 + ` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + role := &Role{} + + err := s.db.QueryRowContext(ctx, query, name).Scan( + &role.ID, + &role.Name, + &role.Level, + &role.Description, + ) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, ErrNotFound + default: + return nil, err + } + } + + return role, nil +} diff --git a/internal/store/storage.go b/internal/store/storage.go new file mode 100644 index 0000000..3cf6561 --- /dev/null +++ b/internal/store/storage.go @@ -0,0 +1,72 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +var ( + QueryTimeout = 5 * time.Second + ErrNotFound = errors.New("resource not found") + ErrConflict = errors.New("resource already exists") + ErrDuplicateEmail = errors.New("email already exists") + ErrDuplicateUsername = errors.New("username already exists") + ErrPasswordNotSet = errors.New("password is empty") +) + +type Storage struct { + Posts interface { + Create(context.Context, *Post) error + GetByID(context.Context, int64) (*Post, error) + GetUserFeed(context.Context, int64, PaginatedFeedQuery) ([]PostWithMetadata, error) + Update(context.Context, *Post) error + Delete(context.Context, int64) error + } + Users interface { + Create(context.Context, *sql.Tx, *User) error + Activate(context.Context, string) error + GetByID(context.Context, int64) (*User, error) + GetByEmail(context.Context, string) (*User, error) + Update(context.Context, *User) error + CreateAndInvite(context.Context, *User, string, time.Duration) error + Delete(context.Context, int64) error + DeleteUser(context.Context, int64) error + } + Comments interface { + Create(context.Context, *Comment) error + GetByPostID(context.Context, int64) ([]Comment, error) + } + Followers interface { + Follow(context.Context, int64, int64) error + Unfollow(context.Context, int64, int64) error + } + Roles interface { + GetByName(context.Context, string) (*Role, error) + } +} + +func NewStorage(db *sql.DB) Storage { + return Storage{ + Posts: &PostStore{db}, + Users: &UserStore{db}, + Comments: &CommentStore{db}, + Followers: &FollowerStore{db}, + Roles: &RoleStore{db}, + } +} + +func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + if err := f(tx); err != nil { + _ = tx.Rollback() + return err + } + + return tx.Commit() +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..6679d35 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,363 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + Email string `json:"email"` + Password password `json:"-"` + IsActive bool `json:"is_active"` + RoleID int64 `json:"role_id"` + Role Role `json:"role"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type password struct { + text *string `validate:"required,password"` + hash []byte +} + +func (p *password) Set(password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + p.text = &password + p.hash = hash + + return nil +} + +type UserStore struct { + db *sql.DB +} + +func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error { + query := ` + INSERT INTO users (first_name, last_name, username, email, password, role_id) + VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + role := user.Role.Name + if role == "" { + role = "user" + } + + err := tx.QueryRowContext( + ctx, + query, + user.FirstName, + user.LastName, + user.Username, + user.Email, + user.Password.hash, + role, + ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + switch { + case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`: + return ErrDuplicateUsername + case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`: + return ErrDuplicateEmail + default: + return err + } + } + + return nil +} + +func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) { + query := ` + SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.* + FROM users + JOIN roles ON (users.role_id = roles.id) + WHERE users.id = $1 + AND is_active = true + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + user := &User{} + err := s.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.FirstName, + &user.LastName, + &user.Username, + &user.Email, + &user.Password.hash, + &user.CreatedAt, + &user.UpdatedAt, + &user.Role.ID, + &user.Role.Name, + &user.Role.Level, + &user.Role.Description, + ) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, ErrNotFound + default: + return nil, err + } + } + + return user, nil +} + +func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) { + query := ` + SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.* + FROM users + JOIN roles ON (users.role_id = roles.id) + WHERE email = $1 + AND is_active = true + ` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + user := &User{} + + err := s.db.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.FirstName, + &user.LastName, + &user.Username, + &user.Email, + &user.Password.hash, + &user.CreatedAt, + &user.UpdatedAt, + &user.Role.ID, + &user.Role.Name, + &user.Role.Level, + &user.Role.Description, + ) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, ErrNotFound + default: + return nil, err + } + } + + return user, nil +} + +func (s *UserStore) Update(ctx context.Context, user *User) error { + query := ` + UPDATE users + SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5 + WHERE id = $6 + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := s.db.ExecContext( + ctx, + query, + user.FirstName, + user.LastName, + user.Username, + user.Email, + user.Password.hash, + user.ID, + ) + if err != nil { + return err + } + + return nil +} + +func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token string, invitationExp time.Duration) error { + return withTx(s.db, ctx, func(tx *sql.Tx) error { + if err := s.Create(ctx, tx, user); err != nil { + return err + } + + if err := s.createUserInvitation(ctx, tx, user.ID, invitationExp, token); err != nil { + return err + } + + return nil + }) +} + +func (s *UserStore) Activate(ctx context.Context, token string) error { + return withTx(s.db, ctx, func(tx *sql.Tx) error { + // Find the user that this token belongs to + user, err := getUserFromInvitation(ctx, tx, token) + if err != nil { + return err + } + + // Update the user's status to active + user.IsActive = true + if err := s.update(ctx, tx, user); err != nil { + return err + } + + // Delete the token from the invitations table + if err := s.deleteUserInvitation(ctx, tx, user.ID); err != nil { + return err + } + + return nil + }) +} + +func (s *UserStore) Delete(ctx context.Context, id int64) error { + return withTx(s.db, ctx, func(tx *sql.Tx) error { + if err := s.delete(ctx, tx, id); err != nil { + return err + } + + if err := s.deleteUserInvitation(ctx, tx, id); err != nil { + return err + } + + return nil + }) +} + +// * Soft delete user +func (s *UserStore) DeleteUser(ctx context.Context, id int64) error { + query := `UPDATE users SET is_active = false WHERE id = $1` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + return nil +} + +func (p *password) Compare(password string) error { + return bcrypt.CompareHashAndPassword(p.hash, []byte(password)) +} + +func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) { + query := ` + SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active + FROM users u + JOIN invitations i ON u.id = i.user_id + WHERE i.token = $1 AND i.expiry > $2 + ` + + hashed := sha256.Sum256([]byte(token)) + hashedToken := hex.EncodeToString(hashed[:]) + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + user := &User{} + err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan( + &user.ID, + &user.FirstName, + &user.LastName, + &user.Username, + &user.Email, + &user.CreatedAt, + &user.UpdatedAt, + &user.IsActive, + ) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, ErrNotFound + default: + return nil, err + } + } + + return user, nil +} + +func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error { + query := ` + INSERT INTO invitations (user_id, token, expiry) + VALUES ($1, $2, $3) + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp)) + if err != nil { + return err + } + + return nil +} + +func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error { + query := ` + UPDATE users + SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5 + WHERE id = $6 + ` + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := tx.ExecContext( + ctx, + query, + user.FirstName, + user.LastName, + user.Username, + user.Email, + user.IsActive, + user.ID, + ) + if err != nil { + return err + } + + return nil +} + +func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error { + query := `DELETE FROM invitations WHERE user_id = $1` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := tx.ExecContext(ctx, query, userID) + if err != nil { + return err + } + + return nil +} + +func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error { + query := `DELETE FROM users WHERE id = $1` + + ctx, cancel := context.WithTimeout(ctx, QueryTimeout) + defer cancel() + + _, err := tx.ExecContext(ctx, query, id) + if err != nil { + return err + } + + return nil +} diff --git a/scripts/db_init.sql b/scripts/db_init.sql new file mode 100644 index 0000000..cdd554a --- /dev/null +++ b/scripts/db_init.sql @@ -0,0 +1 @@ +CREATE EXTENTION IF NOT EXISTS citext; \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..25b5821 --- /dev/null +++ b/web/README.md @@ -0,0 +1,75 @@ +# Nuxt Minimal Starter + +Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/web/app.vue b/web/app.vue new file mode 100644 index 0000000..09f935b --- /dev/null +++ b/web/app.vue @@ -0,0 +1,6 @@ + +