feat: initial commit
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 ./...
|
||||||
@@ -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
|
||||||
@@ -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 "[email protected]"
|
||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.envrc
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0 (2025-03-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* initial commit ([051aedd](https://github.com/FernandoJVideira/LK_API_Temp/commit/051aeddf7d1bf6d537630104bb51e3cef418bcae))
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
File diff suppressed because one or more lines are too long
+210
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+148
@@ -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 [email protected]
|
||||||
|
|
||||||
|
// @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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS posts;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
DROP COLUMN tags;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN tags VARCHAR(100)[] NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS Comments;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts DROP COLUMN version;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts ADD COLUMN version INT NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS followers;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS invitations;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS invitations (
|
||||||
|
token bytea PRIMARY KEY,
|
||||||
|
user_id bigint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
users
|
||||||
|
DROP COLUMN
|
||||||
|
is_active;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
users
|
||||||
|
ADD COLUMN
|
||||||
|
is_active boolean NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
invitations DROP COLUMN expiry;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
invitations
|
||||||
|
ADD COLUMN
|
||||||
|
expiry TIMESTAMP(0) WITH TIME ZONE NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS roles;
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE
|
||||||
|
IF EXISTS users DROP COLUMN role_id;
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
+1105
File diff suppressed because it is too large
Load Diff
+1079
File diff suppressed because it is too large
Load Diff
@@ -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: [email protected]
|
||||||
|
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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+64
@@ -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, ",")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}}
|
||||||
|
|
||||||
|
{{define "body"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
</head>
|
||||||
|
<body> <p>Hi {{.Username}},</p>
|
||||||
|
<p>Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!</p></p>
|
||||||
|
<p>Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:</p>
|
||||||
|
<p><a href="{{.ActivationURL}}">{{.ActivationURL}}</a></p>
|
||||||
|
<p>If you want to activate your account manually copy and paste the code from the link above</p>
|
||||||
|
<p>If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.</p>
|
||||||
|
|
||||||
|
<p>Thanks,</p>
|
||||||
|
<p>The {{.ServiceName}} Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Vendored
+33
@@ -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)
|
||||||
|
}
|
||||||
Vendored
+11
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+22
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+60
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE EXTENTION IF NOT EXISTS citext;
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtWelcome />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
// Your custom configs here
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2024-11-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@nuxt/content',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxt/fonts',
|
||||||
|
'@nuxt/icon',
|
||||||
|
'@nuxt/image',
|
||||||
|
'@nuxt/scripts',
|
||||||
|
'@nuxt/test-utils'
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/content": "3.4.0",
|
||||||
|
"@nuxt/eslint": "1.3.0",
|
||||||
|
"@nuxt/fonts": "0.11.0",
|
||||||
|
"@nuxt/icon": "1.11.0",
|
||||||
|
"@nuxt/image": "1.10.0",
|
||||||
|
"@nuxt/scripts": "0.11.5",
|
||||||
|
"@nuxt/test-utils": "3.17.2",
|
||||||
|
"@unhead/vue": "^2.0.0-rc.8",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"nuxt": "^3.16.1",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user