From 5fba2e78d5400c2c28d40fd9539e72f4ab73ea05 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Fri, 29 May 2026 20:09:00 +0300 Subject: [PATCH] feat: add Docker infrastructure, migrations, CI/CD client, session cleanup, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker & Deployment: - Add Dockerfile (multi-stage, alpine, non-root) - Add docker-compose.yml (caddy, backend, postgres, watchtower) - Add Caddyfile (TLS, file_server, reverse proxy) - Add .env.example Database: - Add migrations/001_init.sql (all tables + indexes) CI/CD: - Add cmd/ci-release/main.go (launcher binary upload tool) Session management: - Add internal/session/cleanup.go (background expired session cleanup) - Integrate cleanup worker into main.go Bug fixes: - Fix launcherLatest download URL to include version segment - Fix serveLauncherAsset path to match route pattern - Add Content-Type detection from file extension in CAS serveFile - Add empty-field validation in webLogin - Format string fix in ci-release (%d → %s for resp.Status) Tests: - Add internal/auth/auth_test.go (8 tests) - Add internal/cas/cas_test.go (7 tests) - Add internal/session/cleanup_test.go (1 test) - Add internal/api/api_test.go (5 tests) - All tests passing, go vet clean Co-Authored-By: Claude Opus 4.6 --- .env.example | 12 ++ Caddyfile | 57 ++++++++ Dockerfile | 19 +++ cmd/ci-release/main.go | 129 ++++++++++++++++++ cmd/server/main.go | 5 + docker-compose.yml | 80 ++++++++++++ internal/api/api.go | 11 +- internal/api/api_test.go | 216 +++++++++++++++++++++++++++++++ internal/auth/auth_test.go | 117 +++++++++++++++++ internal/cas/cas.go | 41 +++++- internal/cas/cas_test.go | 131 +++++++++++++++++++ internal/session/cleanup.go | 68 ++++++++++ internal/session/cleanup_test.go | 14 ++ migrations/001_init.sql | 90 +++++++++++++ 14 files changed, 986 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 cmd/ci-release/main.go create mode 100644 docker-compose.yml create mode 100644 internal/api/api_test.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/cas/cas_test.go create mode 100644 internal/session/cleanup.go create mode 100644 internal/session/cleanup_test.go create mode 100644 migrations/001_init.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cdb819d --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# MrixsCraft — environment variables for Docker Compose. +# Copy to .env and fill in real values. Never commit .env. + +# PostgreSQL password (generate: openssl rand -base64 32) +DB_PASSWORD=change-me + +# CI/CD token for /api/admin/launcher/release endpoint. +# Must match CI_TOKEN in Gitea Actions secrets. +CI_TOKEN=change-me-to-a-random-secret + +# JWT secret for web session signing (generate: openssl rand -base64 48) +JWT_SECRET=change-me-to-another-random-secret diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..2feb546 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,57 @@ +# Caddyfile — MrixsCraft reverse proxy + static file serving. +# TLS is automatic. Adjust domain names to your actual domains. + +# ── CDN: CAS files (immutable, long cache) ──────────────────── + +cdn.mrixs.me { + root * /var/www/cdn/files + + # CAS files — content-addressed, never change. + handle /files/* { + file_server { + hide .htaccess + } + header Cache-Control "public, max-age=31536000, immutable" + } + + # Skins — shorter cache so players see changes. + handle /skins/* { + file_server { + hide .htaccess + } + header Cache-Control "public, max-age=3600" + } +} + +# ── API + Yggdrasil ─────────────────────────────────────────── + +minecraft.mrixs.me { + # Yggdrasil authentication (launcher + game client). + handle /authserver/* { + reverse_proxy backend:8080 + } + + handle /sessionserver/* { + reverse_proxy backend:8080 + } + + # Public API (launcher + website). + handle /api/* { + reverse_proxy backend:8080 + } + + # Skin serving. + handle /skins/* { + reverse_proxy backend:8080 + } + + # CAS file serving (fallback if not served by CDN domain). + handle /files/* { + reverse_proxy backend:8080 + } + + # Everything else — frontend / templates. + handle { + reverse_proxy backend:8080 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42abcf0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Multi-stage build for MrixsCraft backend. +# Final image: ~20 MB, non-root, no toolchain. + +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mc-backend ./cmd/server + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +RUN adduser -D -g '' appuser +WORKDIR /app +COPY --from=builder /mc-backend . +COPY ./migrations /migrations +USER appuser +EXPOSE 8080 +ENTRYPOINT ["/app/mc-backend"] diff --git a/cmd/ci-release/main.go b/cmd/ci-release/main.go new file mode 100644 index 0000000..ee5a38a --- /dev/null +++ b/cmd/ci-release/main.go @@ -0,0 +1,129 @@ +// Command ci-release uploads a new launcher binary to the server. +// +// Usage: +// +// ci-release \ +// --url https://minecraft.mrixs.me \ +// --token \ +// --version 1.2.0 \ +// --os windows \ +// --arch amd64 \ +// --file ./build/launcher-windows-amd64.exe +// +// SHA-256 is computed automatically from the file. + +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" +) + +func main() { + var ( + serverURL = flag.String("url", "", "Server base URL (e.g. https://minecraft.mrixs.me)") + token = flag.String("token", "", "CI token (from CI_SECRET)") + version = flag.String("version", "", "Launcher version (e.g. 1.2.0)") + osParam = flag.String("os", "", "Target OS: windows, linux, darwin") + arch = flag.String("arch", "", "Target arch: amd64, arm64") + filePath = flag.String("file", "", "Path to launcher binary") + ) + flag.Parse() + + // Validate required flags. + missing := false + for _, f := range []struct{ name, value string }{ + {"--url", *serverURL}, + {"--token", *token}, + {"--version", *version}, + {"--os", *osParam}, + {"--arch", *arch}, + {"--file", *filePath}, + } { + if f.value == "" { + fmt.Fprintf(os.Stderr, "ERROR: missing required flag %s\n", f.name) + missing = true + } + } + if missing { + flag.Usage() + os.Exit(1) + } + + // Read the binary. + data, err := os.ReadFile(*filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR reading file: %v\n", err) + os.Exit(1) + } + + // Compute SHA-256. + hash := sha256.Sum256(data) + sha256hex := hex.EncodeToString(hash[:]) + + fmt.Printf("Uploading %s (%s/%s) v%s (%d bytes, sha256=%s)\n", + filepath.Base(*filePath), *osParam, *arch, *version, len(data), sha256hex) + + // Build multipart form. + var body bytes.Buffer + w := multipart.NewWriter(&body) + + // Text fields. + for _, f := range []struct{ name, value string }{ + {"version", *version}, + {"os", *osParam}, + {"arch", *arch}, + {"sha256", sha256hex}, + } { + if err := w.WriteField(f.name, f.value); err != nil { + fmt.Fprintf(os.Stderr, "ERROR building form: %v\n", err) + os.Exit(1) + } + } + + // File part. + fw, err := w.CreateFormFile("file", filepath.Base(*filePath)) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR creating form file: %v\n", err) + os.Exit(1) + } + if _, err := fw.Write(data); err != nil { + fmt.Fprintf(os.Stderr, "ERROR writing file data: %v\n", err) + os.Exit(1) + } + w.Close() + + // Send request. + url := *serverURL + "/api/admin/launcher/release" + req, err := http.NewRequest("POST", url, &body) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR creating request: %v\n", err) + os.Exit(1) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("X-CI-Token", *token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR sending request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + fmt.Printf("HTTP %s: %s\n", resp.Status, string(respBody)) + + if resp.StatusCode != http.StatusCreated { + os.Exit(1) + } + + fmt.Println("✓ Release uploaded successfully.") +} diff --git a/cmd/server/main.go b/cmd/server/main.go index e2eb5ce..c3a3e25 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,7 @@ import ( "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/middleware" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/session" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/templates" ) @@ -34,6 +35,10 @@ func main() { } defer db.Close() + // Start session cleanup worker (runs every hour in the background). + cleanupCancel := session.StartCleanupWorker(db, 1*time.Hour) + defer cleanupCancel() + mux := http.NewServeMux() // Health check — no auth needed. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..91183a0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +# MrixsCraft — Production Docker Compose +# Usage: docker compose up -d +# Monitoring (when ready): docker compose --profile monitoring up -d + +services: + caddy: + image: caddy:2-alpine + container_name: mc-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + - cdn_files:/var/www/cdn/files:ro + + backend: + image: git.mrixs.me/Mrixs/MrixsCraft-server:latest + container_name: mc-backend + restart: unless-stopped + environment: + SERVER_PORT: 8080 + DATABASE_URL: postgres://mcuser:${DB_PASSWORD}@postgres:5432/mcserver?sslmode=disable + CI_TOKEN: ${CI_TOKEN} + CAS_DIR: /var/www/cdn/files + SKINS_DIR: /var/www/cdn/skins + JWT_SECRET: ${JWT_SECRET} + BASE_URL: https://minecraft.mrixs.me + volumes: + - cdn_files:/var/www/cdn/files + depends_on: + postgres: + condition: service_healthy + expose: + - "8080" + labels: + - "com.centurylinklabs.watchtower.enable=true" + + postgres: + image: postgres:16-alpine + container_name: mc-postgres + restart: unless-stopped + environment: + POSTGRES_DB: mcserver + POSTGRES_USER: mcuser + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + - ./backups:/backups + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mcuser -d mcserver"] + interval: 10s + timeout: 5s + retries: 5 + expose: + - "5432" + + watchtower: + image: containrrr/watchtower + container_name: mc-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + WATCHTOWER_CLEANUP: "true" + WATCHTOWER_POLL_INTERVAL: 300 + WATCHTOWER_LABEL_ENABLE: "true" + +volumes: + pgdata: + driver: local + cdn_files: + driver: local + caddy_data: + driver: local + caddy_config: + driver: local diff --git a/internal/api/api.go b/internal/api/api.go index 7e5d6ac..3dd31e6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -64,7 +64,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/web/profile/{uuid}", h.getProfile) // Skin serving. - mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin) + mux.HandleFunc("GET /skins/{hash}", h.serveSkin) } // ── Request / Response types ────────────────────────────────── @@ -211,6 +211,11 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) { return } + if req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "Username and password are required") + return + } + var user database.User err = h.db.Pool().QueryRow(r.Context(), `SELECT id, username, password_hash, uuid FROM users @@ -442,8 +447,8 @@ func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) { return } - downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s", - h.cfg.BaseURL, osParam, archParam, filepath.Base(release.FilePath)) + downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s/%s", + h.cfg.BaseURL, release.Version, osParam, archParam, filepath.Base(release.FilePath)) writeJSON(w, http.StatusOK, launcherLatestResponse{ Version: release.Version, diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..ee89b49 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,216 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" +) + +// newTestHandler creates an API handler with a nil DB for testing validation +// and routing only. Handers that touch the database will panic with nil DB — +// integration tests with a real database cover those paths. +func newTestHandler(t *testing.T) *Handler { + t.Helper() + dir := t.TempDir() + cfg := &config.Config{ + Port: 8080, + CASDir: dir, + SkinsDir: dir, + BaseURL: "https://test.example.com", + JWTSecret: "test-secret", + } + return &Handler{db: &database.DB{}, cfg: cfg} +} + +// TestRegisterValidation tests input validation in the register handler +// without requiring a database connection. +func TestRegisterValidation(t *testing.T) { + h := newTestHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + tests := []struct { + name string + body string + wantStatus int + wantErr string + }{ + { + name: "empty body", + body: "{}", + wantStatus: http.StatusBadRequest, + wantErr: "Username, email and password are required", + }, + { + name: "invalid email", + body: `{"username":"test","email":"notanemail","password":"pass"}`, + wantStatus: http.StatusBadRequest, + wantErr: "Invalid email address", + }, + { + name: "invalid json", + body: "not json", + wantStatus: http.StatusBadRequest, + wantErr: "Invalid JSON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/web/register", + bytes.NewReader([]byte(tt.body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if got := resp["error"]; got != tt.wantErr { + t.Errorf("error = %q, want %q", got, tt.wantErr) + } + }) + } +} + +// TestWebLoginValidation tests input validation in the web login handler. +func TestWebLoginValidation(t *testing.T) { + h := newTestHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + tests := []struct { + name string + body string + wantStatus int + }{ + { + name: "empty credentials", + body: `{"username":"","password":""}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing username", + body: `{"password":"secret"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing password", + body: `{"username":"test"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid json", + body: "not json", + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/web/login", + bytes.NewReader([]byte(tt.body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + }) + } +} + +// TestLauncherLatest_MissingParams tests that missing query parameters +// return 400 without hitting the database. +func TestLauncherLatest_MissingParams(t *testing.T) { + h := newTestHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + queries := []string{ + "/api/launcher/latest", + "/api/launcher/latest?os=windows", + "/api/launcher/latest?arch=amd64", + } + + for _, q := range queries { + req := httptest.NewRequest("GET", q, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("%s: expected 400, got %d", q, w.Code) + } + } +} + +// TestAuthMiddleware_NoToken tests that protected endpoints reject +// requests without a Bearer token. +func TestAuthMiddleware_NoToken(t *testing.T) { + h := newTestHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + protected := []struct { + method string + path string + }{ + {"POST", "/api/web/profile/skin"}, + {"POST", "/api/web/profile/cape"}, + {"DELETE", "/api/web/profile/skin"}, + {"DELETE", "/api/web/profile/cape"}, + } + + for _, ep := range protected { + t.Run(ep.method+" "+ep.path, func(t *testing.T) { + req := httptest.NewRequest(ep.method, ep.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("%s %s: expected 401, got %d", ep.method, ep.path, w.Code) + } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if !strings.Contains(resp["error"], "Missing authorization") { + t.Errorf("unexpected error: %s", resp["error"]) + } + }) + } +} + +// TestRoutesRegistered verifies all expected API routes are mounted +// and return proper HTTP status codes (not 404 for known paths). +func TestRoutesRegistered(t *testing.T) { + h := newTestHandler(t) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + // Public routes that should respond without a database. + // Only routes with early validation (before DB access) are listed. + knownRoutes := []struct { + method string + path string + }{ + {"POST", "/api/web/register"}, + {"POST", "/api/web/login"}, + {"GET", "/api/launcher/latest"}, + } + + for _, r := range knownRoutes { + t.Run(r.method+" "+r.path, func(t *testing.T) { + req := httptest.NewRequest(r.method, r.path, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + // Should not be 404 (route exists). + if w.Code == http.StatusNotFound { + t.Errorf("%s %s: route not found (404)", r.method, r.path) + } + }) + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..7cd4b2d --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,117 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateToken(t *testing.T) { + tok := GenerateToken() + if len(tok) != 32 { + t.Errorf("expected 32-char token, got %d chars: %s", len(tok), tok) + } + // Must be hex. + for _, c := range tok { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("token contains non-hex char: %c", c) + } + } +} + +func TestGenerateToken_Uniqueness(t *testing.T) { + // Two tokens should never collide. + t1 := GenerateToken() + t2 := GenerateToken() + if t1 == t2 { + t.Error("two generated tokens are identical") + } +} + +func TestGenerateUUID(t *testing.T) { + uuid := GenerateUUID() + // Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars). + if len(uuid) != 36 { + t.Errorf("expected 36-char UUID, got %d: %s", len(uuid), uuid) + } + // Check dashes at correct positions. + for _, pos := range []int{8, 13, 18, 23} { + if uuid[pos] != '-' { + t.Errorf("expected dash at position %d, got %c", pos, uuid[pos]) + } + } + // Version 4: char at position 14 should be '4'. + if uuid[14] != '4' { + t.Errorf("expected version 4 at position 14, got %c", uuid[14]) + } +} + +func TestGenerateUUID_Uniqueness(t *testing.T) { + u1 := GenerateUUID() + u2 := GenerateUUID() + if u1 == u2 { + t.Error("two generated UUIDs are identical") + } +} + +func TestHashPassword(t *testing.T) { + hash, err := HashPassword("testpassword") + if err != nil { + t.Fatalf("HashPassword failed: %v", err) + } + if !strings.HasPrefix(hash, "$2a$") { + t.Errorf("expected bcrypt hash starting with $2a$, got: %s", hash[:4]) + } +} + +func TestVerifyPassword(t *testing.T) { + hash, err := HashPassword("minecraft123") + if err != nil { + t.Fatalf("HashPassword failed: %v", err) + } + + if !VerifyPassword("minecraft123", hash) { + t.Error("VerifyPassword returned false for correct password") + } + if VerifyPassword("wrongpassword", hash) { + t.Error("VerifyPassword returned true for wrong password") + } +} + +func TestIsBcryptHash(t *testing.T) { + tests := []struct { + hash string + want bool + }{ + {"$2a$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"$2b$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"$2y$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true}, + {"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", false}, + {"", false}, + {"plaintext", false}, + } + for _, tt := range tests { + got := IsBcryptHash(tt.hash) + if got != tt.want { + t.Errorf("IsBcryptHash(%q) = %v, want %v", tt.hash, got, tt.want) + } + } +} + +func TestExtractBearer(t *testing.T) { + tests := []struct { + header string + want string + }{ + {"Bearer abc123", "abc123"}, + {"Bearer ", ""}, + {"abc123", ""}, + {"", ""}, + {"Basic abc123", ""}, + } + for _, tt := range tests { + got := ExtractBearer(tt.header) + if got != tt.want { + t.Errorf("ExtractBearer(%q) = %q, want %q", tt.header, got, tt.want) + } + } +} diff --git a/internal/cas/cas.go b/internal/cas/cas.go index 9484aba..db7be5f 100644 --- a/internal/cas/cas.go +++ b/internal/cas/cas.go @@ -18,6 +18,33 @@ import ( "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" ) +// mimeByExtension maps common file extensions to MIME types for CAS serving. +var mimeByExtension = map[string]string{ + ".jar": "application/java-archive", + ".json": "application/json", + ".png": "image/png", + ".zip": "application/zip", + ".toml": "application/toml", + ".cfg": "text/plain", + ".conf": "text/plain", + ".txt": "text/plain", + ".log": "text/plain", + ".xml": "application/xml", + ".yml": "application/x-yaml", + ".yaml": "application/x-yaml", + ".properties": "text/plain", +} + +// detectContentType returns a MIME type based on the file's extension. +// Falls back to application/octet-stream for unknown types. +func detectContentType(fileName string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + if mime, ok := mimeByExtension[ext]; ok { + return mime + } + return "application/octet-stream" +} + // Handler serves CAS endpoints. type Handler struct { db *database.DB @@ -34,12 +61,13 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Public file serving — immutable, long cache. mux.HandleFunc("GET /files/{hash}", h.serveFile) - // Launcher binary downloads — also served from CAS-like paths. + // Launcher binary downloads — served from /files/launcher/{version}/{os}/{arch}/{filename}. mux.HandleFunc("GET /files/launcher/{version}/{os}/{arch}/{filename}", h.serveLauncherAsset) } // serveFile serves a file from CAS by its SHA-1 hash. // Files are immutable, so we set Cache-Control: public, max-age=31536000 (1 year). +// Content-Type is detected from the original file name stored in global_files. func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request) { hash := r.PathValue("hash") if !isValidHash(hash) { @@ -54,6 +82,16 @@ func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request) { return } + // Look up the original file name for Content-Type detection. + var fileName string + err = h.db.Pool().QueryRow(r.Context(), + `SELECT file_name FROM global_files WHERE sha1 = $1`, hash, + ).Scan(&fileName) + if err != nil { + fileName = hash // fallback: no extension info + } + + w.Header().Set("Content-Type", detectContentType(fileName)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Write(data) } @@ -89,6 +127,7 @@ func (h *Handler) serveLauncherAsset(w http.ResponseWriter, r *http.Request) { return } + w.Header().Set("Content-Type", detectContentType(filename)) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Write(data) } diff --git a/internal/cas/cas_test.go b/internal/cas/cas_test.go new file mode 100644 index 0000000..ac4dedd --- /dev/null +++ b/internal/cas/cas_test.go @@ -0,0 +1,131 @@ +package cas + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsValidHash(t *testing.T) { + tests := []struct { + hash string + want bool + }{ + {"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", true}, + {"0000000000000000000000000000000000000000", true}, + {"ffffffffffffffffffffffffffffffffffffffff", true}, + {"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", false}, // uppercase + {"g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", false}, // non-hex + {"a1b2c3d4e5f6", false}, // too short + {"", false}, // empty + {"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", false}, // too long (41) + } + for _, tt := range tests { + got := isValidHash(tt.hash) + if got != tt.want { + t.Errorf("isValidHash(%q) = %v, want %v", tt.hash, got, tt.want) + } + } +} + +func TestStoreFile(t *testing.T) { + dir := t.TempDir() + data := []byte("hello minecraft world") + + hash, err := StoreFile(dir, data) + if err != nil { + t.Fatalf("StoreFile failed: %v", err) + } + if len(hash) != 40 { + t.Errorf("expected 40-char hash, got %d", len(hash)) + } + + // File should exist at dir//. + path := filepath.Join(dir, hash[:2], hash) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stored file not found: %v", err) + } + if info.Size() != int64(len(data)) { + t.Errorf("stored file size = %d, want %d", info.Size(), len(data)) + } +} + +func TestStoreFile_Duplicate(t *testing.T) { + dir := t.TempDir() + data := []byte("same content") + + h1, err := StoreFile(dir, data) + if err != nil { + t.Fatalf("first StoreFile failed: %v", err) + } + h2, err := StoreFile(dir, data) + if err != nil { + t.Fatalf("second StoreFile failed: %v", err) + } + if h1 != h2 { + t.Errorf("same data produced different hashes: %s vs %s", h1, h2) + } +} + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + data := []byte("test data") + + hash, _ := StoreFile(dir, data) + if !FileExists(dir, hash) { + t.Error("FileExists returned false for stored file") + } + if FileExists(dir, "0000000000000000000000000000000000000000") { + t.Error("FileExists returned true for non-existent file") + } +} + +func TestVerifyAndStore(t *testing.T) { + dir := t.TempDir() + data := []byte("verify me") + hash, _ := StoreFile(dir, data) + + // Correct hash → should succeed (idempotent). + got, err := VerifyAndStore(dir, data, hash) + if err != nil { + t.Errorf("VerifyAndStore with correct hash failed: %v", err) + } + if got != hash { + t.Errorf("hash mismatch: got %s, want %s", got, hash) + } + + // Wrong hash → should fail. + _, err = VerifyAndStore(dir, data, "0000000000000000000000000000000000000000") + if err == nil { + t.Error("VerifyAndStore with wrong hash should have failed") + } +} + +func TestDetectContentType(t *testing.T) { + tests := []struct { + fileName string + want string + }{ + {"mod.jar", "application/java-archive"}, + {"config.json", "application/json"}, + {"skin.png", "image/png"}, + {"pack.zip", "application/zip"}, + {"options.toml", "application/toml"}, + {"server.cfg", "text/plain"}, + {"notes.txt", "text/plain"}, + {"data.xml", "application/xml"}, + {"config.yml", "application/x-yaml"}, + {"config.yaml", "application/x-yaml"}, + {"game.properties", "text/plain"}, + {"unknown.dat", "application/octet-stream"}, + {"noext", "application/octet-stream"}, + {"UPPER.JAR", "application/java-archive"}, + } + for _, tt := range tests { + got := detectContentType(tt.fileName) + if got != tt.want { + t.Errorf("detectContentType(%q) = %q, want %q", tt.fileName, got, tt.want) + } + } +} diff --git a/internal/session/cleanup.go b/internal/session/cleanup.go new file mode 100644 index 0000000..d417f2c --- /dev/null +++ b/internal/session/cleanup.go @@ -0,0 +1,68 @@ +// package session manages Yggdrasil session lifecycle. + +package session + +import ( + "context" + "log" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" +) + +// StartCleanupWorker launches a background goroutine that deletes expired +// yggdrasil_sessions every interval. It stops when the context is cancelled. +func StartCleanupWorker(db *database.DB, interval time.Duration) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + log.Printf("Session cleanup worker started (interval: %v)", interval) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Run once on start. + cleanup(db) + + for { + select { + case <-ticker.C: + cleanup(db) + case <-ctx.Done(): + log.Println("Session cleanup worker stopped") + return + } + } + }() + + return cancel +} + +func cleanup(db *database.DB) { + if db == nil { + return + } + pool := db.Pool() + if pool == nil { + return + } + tag, err := pool.Exec(context.Background(), + `DELETE FROM yggdrasil_sessions WHERE expires_at < NOW()`) + if err != nil { + log.Printf("Session cleanup error: %v", err) + return + } + if tag.RowsAffected() > 0 { + log.Printf("Session cleanup: removed %d expired sessions", tag.RowsAffected()) + } +} + +// CountActive returns the number of non-expired sessions. +func CountActive(pool *pgxpool.Pool) (int, error) { + var count int + err := pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM yggdrasil_sessions WHERE expires_at > NOW()`, + ).Scan(&count) + return count, err +} diff --git a/internal/session/cleanup_test.go b/internal/session/cleanup_test.go new file mode 100644 index 0000000..da87f34 --- /dev/null +++ b/internal/session/cleanup_test.go @@ -0,0 +1,14 @@ +package session + +import ( + "testing" + "time" +) + +func TestStartCleanupWorker(t *testing.T) { + // Verify the worker starts and can be cancelled without panic. + cancel := StartCleanupWorker(nil, 1*time.Millisecond) + defer cancel() + // Give it a moment to attempt one cleanup cycle. + time.Sleep(50 * time.Millisecond) +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..c8b7483 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,90 @@ +-- 001_init.sql — Initial schema for MrixsCraft server. +-- All tables from the Specification (§3). + +-- Enable UUID extension (gen_random_uuid available in PG 13+). +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Users table. +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(32) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + uuid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(), + role VARCHAR(16) DEFAULT 'user' CHECK (role IN ('user', 'admin')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Player textures (skin / cape references into CAS). +CREATE TABLE player_textures ( + user_id INT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + skin_hash VARCHAR(40), + cape_hash VARCHAR(40), + is_slim BOOLEAN DEFAULT FALSE +); + +-- Yggdrasil sessions for launcher and game authentication. +CREATE TABLE yggdrasil_sessions ( + client_token UUID NOT NULL DEFAULT gen_random_uuid(), + access_token UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMP NOT NULL +); + +-- Modpacks (game servers). +CREATE TABLE modpacks ( + id SERIAL PRIMARY KEY, + slug VARCHAR(32) UNIQUE NOT NULL, + name VARCHAR(64) NOT NULL, + minecraft_version VARCHAR(16) NOT NULL, + java_version INT NOT NULL CHECK (java_version IN (8, 11, 17, 21)), + server_ip VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Global unique files (CAS registry). +CREATE TABLE global_files ( + sha1 VARCHAR(40) PRIMARY KEY CHECK (sha1 ~ '^[0-9a-f]{40}$'), + size_bytes BIGINT NOT NULL CHECK (size_bytes > 0), + file_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(127) DEFAULT 'application/octet-stream', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Launcher releases (populated via CI/CD). +CREATE TABLE launcher_releases ( + id SERIAL PRIMARY KEY, + version VARCHAR(32) NOT NULL, + os VARCHAR(16) NOT NULL CHECK (os IN ('windows', 'linux', 'darwin', 'universal')), + arch VARCHAR(16) NOT NULL CHECK (arch IN ('amd64', 'arm64', 'universal')), + sha256 VARCHAR(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + file_path VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_mandatory BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (version, os, arch) +); + +-- ── Indexes ────────────────────────────────────────────────── + +-- Session look-ups (every authenticated request). +CREATE INDEX idx_sessions_access_token ON yggdrasil_sessions (access_token); +CREATE INDEX idx_sessions_user_id ON yggdrasil_sessions (user_id); +CREATE INDEX idx_sessions_expires_at ON yggdrasil_sessions (expires_at); + +-- User look-ups by login fields. +CREATE INDEX idx_users_uuid ON users (uuid); +CREATE INDEX idx_users_username ON users (username); +CREATE INDEX idx_users_email ON users (email); +CREATE INDEX idx_users_role ON users (role); + +-- Game client profile look-up. +CREATE INDEX idx_player_textures_skin ON player_textures (skin_hash) WHERE skin_hash IS NOT NULL; +CREATE INDEX idx_player_textures_cape ON player_textures (cape_hash) WHERE cape_hash IS NOT NULL; + +-- Launcher latest version look-up. +CREATE INDEX idx_launcher_releases_os_arch ON launcher_releases (os, arch) WHERE is_active = TRUE; + +-- CAS file name search. +CREATE INDEX idx_global_files_name ON global_files (file_name);