From 08ff1da46999c3efab74f8321c5c529d3931029f Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Tue, 17 Mar 2026 12:21:48 +0100 Subject: [PATCH] add initial project structure with configuration, models, and API key authentication --- .air.toml | 5 +++ .env.example | 3 ++ .gitignore | 36 +++++++++++++++ go.mod | 16 +++++++ go.sum | 21 +++++++++ internal/config/config.go | 34 +++++++++++++++ internal/database/database.go | 23 ++++++++++ internal/handlers/example.go | 24 ++++++++++ internal/handlers/health.go | 27 ++++++++++++ internal/middleware/apikey.go | 32 ++++++++++++++ internal/models/bug_report.go | 26 +++++++++++ internal/models/bug_report_file.go | 21 +++++++++ internal/models/rate_limit_hwid.go | 9 ++++ internal/models/session.go | 11 +++++ internal/models/user.go | 21 +++++++++ main.go | 70 ++++++++++++++++++++++++++++++ 16 files changed, 379 insertions(+) create mode 100644 .air.toml create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/handlers/example.go create mode 100644 internal/handlers/health.go create mode 100644 internal/middleware/apikey.go create mode 100644 internal/models/bug_report.go create mode 100644 internal/models/bug_report_file.go create mode 100644 internal/models/rate_limit_hwid.go create mode 100644 internal/models/session.go create mode 100644 internal/models/user.go create mode 100644 main.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..3d003b0 --- /dev/null +++ b/.air.toml @@ -0,0 +1,5 @@ +[build] + cmd = "go build -o ./tmp/main.exe ." + bin = "./tmp/main.exe" + include_ext = ["go"] + exclude_dir = ["tmp", "vendor"] diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..df0aa7e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +PORT=8080 +DB_DSN=root:secret@tcp(127.0.0.1:3306)/emly?parseTime=true&loc=UTC +API_KEYS=key-one,key-two,key-three diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28cbcf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + + +# Claude +.claude/ \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a60502a --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module emly-api-go + +go 1.26 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/httprate v0.14.1 + github.com/go-sql-driver/mysql v1.8.1 + github.com/joho/godotenv v1.5.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/jmoiron/sqlx v1.3.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba0a05e --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= +github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..417ab97 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" + "strings" +) + +type Config struct { + Port string + DSN string + APIKeys []string +} + +func Load() *Config { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + raw := os.Getenv("API_KEYS") + var keys []string + for _, k := range strings.Split(raw, ",") { + k = strings.TrimSpace(k) + if k != "" { + keys = append(keys, k) + } + } + + return &Config{ + Port: port, + DSN: os.Getenv("DB_DSN"), + APIKeys: keys, + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..ce97450 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,23 @@ +package database + +import ( + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + + "emly-api-go/internal/config" +) + +func Connect(cfg *config.Config) (*sqlx.DB, error) { + db, err := sqlx.Connect("mysql", cfg.DSN) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + return db, nil +} diff --git a/internal/handlers/example.go b/internal/handlers/example.go new file mode 100644 index 0000000..f062054 --- /dev/null +++ b/internal/handlers/example.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" +) + +var ExampleGet http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "example GET"}) +} + +var ExamplePost http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + defer r.Body.Close() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "message": "example POST", + "received": string(body), + }) +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..91d0092 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/jmoiron/sqlx" +) + +func Health(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + w.Header().Set("Content-Type", "application/json") + + if err := db.PingContext(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "db": "error"}) + return + } + + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "db": "ok"}) + } +} diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go new file mode 100644 index 0000000..45ba10e --- /dev/null +++ b/internal/middleware/apikey.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "encoding/json" + "net/http" + + "github.com/jmoiron/sqlx" + + "emly-api-go/internal/config" +) + +func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler { + cfg := config.Load() + + allowed := make(map[string]struct{}, len(cfg.APIKeys)) + for _, k := range cfg.APIKeys { + allowed[k] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-Key") + if _, ok := allowed[key]; !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/models/bug_report.go b/internal/models/bug_report.go new file mode 100644 index 0000000..194d6d5 --- /dev/null +++ b/internal/models/bug_report.go @@ -0,0 +1,26 @@ +package models + +import ( + "encoding/json" + "time" +) + +type BugReportStatus string + +const ( + BugReportStatusOpen BugReportStatus = "open" + BugReportStatusInProgress BugReportStatus = "in_progress" + BugReportStatusResolved BugReportStatus = "resolved" + BugReportStatusClosed BugReportStatus = "closed" +) + +type BugReport struct { + ID int64 `db:"id" json:"id"` + UserID *int64 `db:"user_id" json:"user_id"` + Title string `db:"title" json:"title"` + Description string `db:"description" json:"description"` + Status BugReportStatus `db:"status" json:"status"` + SystemInfo json.RawMessage `db:"system_info" json:"system_info,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/internal/models/bug_report_file.go b/internal/models/bug_report_file.go new file mode 100644 index 0000000..d7a9cce --- /dev/null +++ b/internal/models/bug_report_file.go @@ -0,0 +1,21 @@ +package models + +import "time" + +type FileRole string + +const ( + FileRoleAttachment FileRole = "attachment" + FileRoleScreenshot FileRole = "screenshot" + FileRoleLog FileRole = "log" +) + +type BugReportFile struct { + ID int64 `db:"id" json:"id"` + BugReportID int64 `db:"bug_report_id" json:"bug_report_id"` + Filename string `db:"filename" json:"filename"` + MimeType string `db:"mime_type" json:"mime_type"` + Role FileRole `db:"role" json:"role"` + Data []byte `db:"data" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} diff --git a/internal/models/rate_limit_hwid.go b/internal/models/rate_limit_hwid.go new file mode 100644 index 0000000..f112c90 --- /dev/null +++ b/internal/models/rate_limit_hwid.go @@ -0,0 +1,9 @@ +package models + +import "time" + +type RateLimitHWID struct { + HWID string `db:"hwid" json:"hwid"` + Requests int `db:"requests" json:"requests"` + WindowStart time.Time `db:"window_start" json:"window_start"` +} diff --git a/internal/models/session.go b/internal/models/session.go new file mode 100644 index 0000000..c1e0eb8 --- /dev/null +++ b/internal/models/session.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type Session struct { + ID string `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..1a4b82b --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,21 @@ +package models + +import "time" + +type UserRole string + +const ( + UserRoleAdmin UserRole = "admin" + UserRoleUser UserRole = "user" +) + +type User struct { + ID int64 `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Email string `db:"email" json:"email"` + PasswordHash string `db:"password_hash" json:"-"` + Role UserRole `db:"role" json:"role"` + Enabled bool `db:"enabled" json:"enabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..23807b8 --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" + "github.com/joho/godotenv" + + "emly-api-go/internal/config" + "emly-api-go/internal/database" + "emly-api-go/internal/handlers" + apimw "emly-api-go/internal/middleware" +) + +func main() { + // Load .env (ignored if not present in production) + _ = godotenv.Load() + + cfg := config.Load() + + db, err := database.Connect(cfg) + if err != nil { + log.Fatalf("database connection failed: %v", err) + } + defer db.Close() + + r := chi.NewRouter() + + // ── Global middleware ──────────────────────────────────────────────────── + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(30 * time.Second)) + + // ── Global rate-limit: 100 req / min per IP ────────────────────────────── + r.Use(httprate.LimitByIP(100, time.Minute)) + + // ── Public routes ──────────────────────────────────────────────────────── + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("emly-api-go")) + }) + + r.Route("/api/v1", func(r chi.Router) { + // Health – public, no API key required + r.Get("/health", handlers.Health(db)) + + // ── Protected routes: require valid API key ────────────────────────── + r.Group(func(r chi.Router) { + r.Use(apimw.APIKeyAuth(db)) + + // Tighter rate-limit on protected group: 30 req / min per IP + r.Use(httprate.LimitByIP(30, time.Minute)) + + r.Get("/example", handlers.ExampleGet) + r.Post("/example", handlers.ExamplePost) + }) + }) + + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("server listening on %s", addr) + if err := http.ListenAndServe(addr, r); err != nil { + log.Fatalf("server error: %v", err) + } +}