add initial project structure with configuration, models, and API key authentication
This commit is contained in:
5
.air.toml
Normal file
5
.air.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[build]
|
||||||
|
cmd = "go build -o ./tmp/main.exe ."
|
||||||
|
bin = "./tmp/main.exe"
|
||||||
|
include_ext = ["go"]
|
||||||
|
exclude_dir = ["tmp", "vendor"]
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -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
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -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/
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
21
go.sum
Normal file
21
go.sum
Normal file
@@ -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=
|
||||||
34
internal/config/config.go
Normal file
34
internal/config/config.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/database/database.go
Normal file
23
internal/database/database.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
24
internal/handlers/example.go
Normal file
24
internal/handlers/example.go
Normal file
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
27
internal/handlers/health.go
Normal file
27
internal/handlers/health.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/middleware/apikey.go
Normal file
32
internal/middleware/apikey.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/models/bug_report.go
Normal file
26
internal/models/bug_report.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
21
internal/models/bug_report_file.go
Normal file
21
internal/models/bug_report_file.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
9
internal/models/rate_limit_hwid.go
Normal file
9
internal/models/rate_limit_hwid.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
11
internal/models/session.go
Normal file
11
internal/models/session.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
21
internal/models/user.go
Normal file
21
internal/models/user.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
70
main.go
Normal file
70
main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user