feat: add Docker infrastructure, migrations, CI/CD client, session cleanup, tests
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 <noreply@anthropic.com>
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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
|
||||
57
Caddyfile
Normal file
57
Caddyfile
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -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"]
|
||||
129
cmd/ci-release/main.go
Normal file
129
cmd/ci-release/main.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Command ci-release uploads a new launcher binary to the server.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// ci-release \
|
||||
// --url https://minecraft.mrixs.me \
|
||||
// --token <CI_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.")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
80
docker-compose.yml
Normal file
80
docker-compose.yml
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
216
internal/api/api_test.go
Normal file
216
internal/api/api_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
117
internal/auth/auth_test.go
Normal file
117
internal/auth/auth_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
131
internal/cas/cas_test.go
Normal file
131
internal/cas/cas_test.go
Normal file
@@ -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/<prefix>/<hash>.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
internal/session/cleanup.go
Normal file
68
internal/session/cleanup.go
Normal file
@@ -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
|
||||
}
|
||||
14
internal/session/cleanup_test.go
Normal file
14
internal/session/cleanup_test.go
Normal file
@@ -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)
|
||||
}
|
||||
90
migrations/001_init.sql
Normal file
90
migrations/001_init.sql
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user