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:
2026-05-29 20:09:00 +03:00
parent 81c42e1a9a
commit 5fba2e78d5
14 changed files with 986 additions and 4 deletions

12
.env.example Normal file
View 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
View 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
View 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
View 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.")
}

View File

@@ -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
View 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

View File

@@ -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
View 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
View 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)
}
}
}

View File

@@ -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
View 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)
}
}
}

View 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
}

View 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
View 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);