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

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.