Compare commits
43 Commits
551c75a232
...
7a8e79123f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a8e79123f | |||
| a17b80230a | |||
| 75ea7c70c2 | |||
| 5bd8a549ca | |||
| b9e986d25a | |||
| f765fecf24 | |||
| f4f7a52749 | |||
| b8c136878b | |||
| 329c0d3fda | |||
| 6a8213a234 | |||
| 7ae0f44fd6 | |||
| 3a148fabe2 | |||
| a143399643 | |||
| 1c69721b47 | |||
| d419d59fe3 | |||
| 74ad023a36 | |||
| c22c860ec5 | |||
| 3f2fe0043a | |||
| 21d48200f5 | |||
| 2f1f1ef7d6 | |||
| 394003d8c0 | |||
| 008d9a129e | |||
| e94cd4c23c | |||
| 3e4b97e262 | |||
| 51e45d9327 | |||
| 79ebed5b01 | |||
| 3499963205 | |||
| 7b3b97c5f8 | |||
| 4efcc770ac | |||
| 8d1f956a8b | |||
| 69eb6ddd5f | |||
| 08aaa7a3c8 | |||
| 7ad02cb1b2 | |||
| e1cc999ea8 | |||
| d418ae2b54 | |||
| 5fba2e78d5 | |||
| 81c42e1a9a | |||
| 01cce981c5 | |||
| e4fea937aa | |||
| 2f07fbf379 | |||
| 475ff9bfa2 | |||
| d205320e0e | |||
| aa7d3a8509 |
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
|
||||||
134
.gitea/workflows/ci.yml
Normal file
134
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Required repo secret: PACKAGES_TOKEN
|
||||||
|
# Generate at: Gitea → User Settings → Applications → Generate New Token
|
||||||
|
# Scopes: read:package, write:package
|
||||||
|
# Save as: Repository → Settings → Actions → Secrets → PACKAGES_TOKEN
|
||||||
|
# Note: GITHUB_TOKEN is read-only for packages in Gitea (issue #23642).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["master"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.25"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
key: go-lint-${{ runner.os }}-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
go-lint-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Download Go modules
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: gofmt check
|
||||||
|
run: |
|
||||||
|
fmt=$(gofmt -l .)
|
||||||
|
if [ -n "$fmt" ]; then
|
||||||
|
echo "Files needing formatting:"
|
||||||
|
echo "$fmt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Cache Go modules and build cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
key: go-test-${{ runner.os }}-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
go-test-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Download Go modules
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test ./... -v -race -cover
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- name: Cache Go modules and build cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
key: go-build-${{ runner.os }}-${{ hashFiles('go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
go-build-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Download Go modules
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Build binary
|
||||||
|
run: go build -o mrixscraft-server ./cmd/server
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
if: github.ref_name == 'master'
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.mrixs.me
|
||||||
|
username: ${{ gitea.repository_owner }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gitea.mrixs.me/mrixs/mrixscraft-server:latest
|
||||||
|
gitea.mrixs.me/mrixs/mrixscraft-server:${{ github.sha }}
|
||||||
|
cache-from: type=registry,ref=gitea.mrixs.me/mrixs/mrixscraft-server:buildcache
|
||||||
|
cache-to: type=registry,ref=gitea.mrixs.me/mrixs/mrixscraft-server:buildcache,mode=max
|
||||||
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.25-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 root
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/bin/sh", "-c", "chown -R appuser:appuser /var/www/cdn/skins && exec /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.")
|
||||||
|
}
|
||||||
@@ -1,26 +1,109 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/admin"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/cas"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := os.Getenv("SERVER_PORT")
|
ctx := context.Background()
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db, err := database.Open(ctx, cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
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()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
// Health check — no auth needed.
|
||||||
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprintln(w, "ok")
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Printf("MrixsCraft Server starting on :%s", port)
|
// Yggdrasil API.
|
||||||
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
authHandler := auth.NewHandler(db, cfg)
|
||||||
log.Fatal(err)
|
authHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Public API.
|
||||||
|
apiHandler := api.NewHandler(db, cfg)
|
||||||
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// CAS (Content-Addressable Storage) file serving.
|
||||||
|
casHandler := cas.NewHandler(db, cfg)
|
||||||
|
casHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Admin panel.
|
||||||
|
adminHandler := admin.NewHandler(db, cfg)
|
||||||
|
adminHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Templates (web UI).
|
||||||
|
templatesHandler := templates.NewHandler(db, cfg)
|
||||||
|
templatesHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Wrapper chain: Recovery → Logging → RateLimit → CORS → mux.
|
||||||
|
// Recovery must be outermost so it catches panics in all inner layers.
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = middleware.CORS(handler)
|
||||||
|
handler = middleware.NewRateLimiter(30, time.Minute, 60).Limit(handler)
|
||||||
|
handler = middleware.Logging(handler)
|
||||||
|
handler = middleware.Recovery(handler)
|
||||||
|
|
||||||
|
addr := ":" + itoa(cfg.Port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGINT/SIGTERM.
|
||||||
|
done := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("MrixsCraft Server starting on %s", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-done
|
||||||
|
log.Println("Shutting down…")
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("Shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("Stopped.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa converts int to string (stdlib alias to avoid fmt import just for this).
|
||||||
|
func itoa(n int) string {
|
||||||
|
return strconv.Itoa(n)
|
||||||
}
|
}
|
||||||
|
|||||||
84
docker-compose.yml
Normal file
84
docker-compose.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
|
- cdn_skins:/var/www/cdn/skins:ro
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: gitea.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
|
||||||
|
- cdn_skins:/var/www/cdn/skins
|
||||||
|
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: nickfedor/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
|
||||||
|
cdn_skins:
|
||||||
|
driver: local
|
||||||
|
caddy_data:
|
||||||
|
driver: local
|
||||||
|
caddy_config:
|
||||||
|
driver: local
|
||||||
15
go.mod
15
go.mod
@@ -1,3 +1,14 @@
|
|||||||
module github.com/Mrixs/MrixsCraft-server
|
module gitea.mrixs.me/Mrixs/MrixsCraft-server
|
||||||
|
|
||||||
go 1.22
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
28
go.sum
Normal file
28
go.sum
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1,2 +1,656 @@
|
|||||||
// package admin implements admin panel endpoints (modpack management, CI/CD release).
|
// package admin implements admin panel endpoints (modpack management, CI/CD release).
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves admin endpoints.
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new admin handler.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
return &Handler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the admin endpoints.
|
||||||
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /api/admin/modpacks", h.auth(h.listModpacks))
|
||||||
|
mux.HandleFunc("POST /api/admin/modpacks", h.auth(h.createModpack))
|
||||||
|
mux.HandleFunc("PUT /api/admin/modpacks/{id}", h.auth(h.updateModpack))
|
||||||
|
mux.HandleFunc("DELETE /api/admin/modpacks/{id}", h.auth(h.deleteModpack))
|
||||||
|
mux.HandleFunc("POST /api/admin/modpacks/{slug}/upload", h.auth(h.uploadFiles))
|
||||||
|
mux.HandleFunc("POST /api/admin/modpacks/{slug}/manifest", h.auth(h.generateManifest))
|
||||||
|
mux.HandleFunc("POST /api/admin/launcher/release", h.ciToken(h.launcherRelease))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const ctxKeyUserID ctxKey = 0
|
||||||
|
|
||||||
|
func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := auth.ExtractBearer(r.Header.Get("Authorization"))
|
||||||
|
if token == "" {
|
||||||
|
utils.WriteError(w, http.StatusUnauthorized, "Missing authorization token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int
|
||||||
|
var role string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.id, u.role
|
||||||
|
FROM yggdrasil_sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.access_token = $1 AND s.expires_at > NOW()`,
|
||||||
|
token,
|
||||||
|
).Scan(&userID, &role)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusUnauthorized, "Invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role != "admin" {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "Admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), ctxKeyUserID, userID)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.Header.Get("X-CI-Token")
|
||||||
|
if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(h.cfg.CIsecret)) != 1 {
|
||||||
|
utils.WriteError(w, http.StatusForbidden, "Invalid CI token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modpack CRUD ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
type modpackRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
MinecraftVersion string `json:"minecraft_version"`
|
||||||
|
JavaVersion int `json:"java_version"`
|
||||||
|
ServerIP string `json:"server_ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modpackResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
MinecraftVersion string `json:"minecraft_version"`
|
||||||
|
JavaVersion int `json:"java_version"`
|
||||||
|
ServerIP string `json:"server_ip"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listModpacks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.db.Pool().Query(r.Context(),
|
||||||
|
`SELECT id, slug, name, minecraft_version, java_version, server_ip, is_active
|
||||||
|
FROM modpacks ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var modpacks []modpackResponse
|
||||||
|
for rows.Next() {
|
||||||
|
var m modpackResponse
|
||||||
|
if err := rows.Scan(&m.ID, &m.Slug, &m.Name, &m.MinecraftVersion, &m.JavaVersion, &m.ServerIP, &m.IsActive); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modpacks = append(modpacks, m)
|
||||||
|
}
|
||||||
|
utils.WriteJSON(w, http.StatusOK, modpacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse multipart form (for both modpack data and file uploads)
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil { // 500 MB max
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract modpack data from form
|
||||||
|
slug := r.FormValue("slug")
|
||||||
|
name := r.FormValue("name")
|
||||||
|
minecraftVersion := r.FormValue("minecraft_version")
|
||||||
|
javaVersionStr := r.FormValue("java_version")
|
||||||
|
serverIP := r.FormValue("server_ip")
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if slug == "" || name == "" || minecraftVersion == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "slug, name, and minecraft_version are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse java_version
|
||||||
|
javaVersion := 8 // default
|
||||||
|
if javaVersionStr != "" {
|
||||||
|
if parsed, err := strconv.Atoi(javaVersionStr); err == nil {
|
||||||
|
javaVersion = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert modpack into database
|
||||||
|
_, err := h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO modpacks (slug, name, minecraft_version, java_version, server_ip, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, true)`,
|
||||||
|
slug, name, minecraftVersion, javaVersion, serverIP,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusConflict, "Modpack with this slug already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file uploads if any
|
||||||
|
files := r.MultipartForm.File["files"]
|
||||||
|
if len(files) > 0 {
|
||||||
|
// Create instance directory for this modpack
|
||||||
|
uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug)
|
||||||
|
os.MkdirAll(uploadDir, 0o755)
|
||||||
|
|
||||||
|
var uploaded []string
|
||||||
|
for _, fh := range files {
|
||||||
|
src, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
src.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") {
|
||||||
|
extracted, err := h.extractZip(data, uploadDir)
|
||||||
|
if err == nil {
|
||||||
|
uploaded = append(uploaded, extracted...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
destDir := filepath.Join(h.cfg.CASDir, hash[:2])
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(destDir, hash), data, 0o644)
|
||||||
|
|
||||||
|
h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (sha1) DO NOTHING`,
|
||||||
|
hash, int64(len(data)), fh.Filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
instPath := filepath.Join(uploadDir, "mods", fh.Filename)
|
||||||
|
os.MkdirAll(filepath.Dir(instPath), 0o755)
|
||||||
|
os.WriteFile(instPath, data, 0o644)
|
||||||
|
|
||||||
|
uploaded = append(uploaded, fh.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Optionally generate manifest after upload
|
||||||
|
// For now, just return success with upload info
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"status": "created",
|
||||||
|
"uploaded": uploaded,
|
||||||
|
"count": len(uploaded),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No files uploaded, just created modpack
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, map[string]string{"status": "created"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid modpack ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form (for both modpack data and file uploads)
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil { // 500 MB max
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract modpack data from form
|
||||||
|
name := r.FormValue("name")
|
||||||
|
slug := r.FormValue("slug")
|
||||||
|
minecraftVersion := r.FormValue("minecraft_version")
|
||||||
|
javaVersionStr := r.FormValue("java_version")
|
||||||
|
serverIP := r.FormValue("server_ip")
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if name == "" || slug == "" || minecraftVersion == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "name, slug, and minecraft_version are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse java_version
|
||||||
|
javaVersion := 8 // default
|
||||||
|
if javaVersionStr != "" {
|
||||||
|
if parsed, err := strconv.Atoi(javaVersionStr); err == nil {
|
||||||
|
javaVersion = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modpack in database
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE modpacks SET name=$1, slug=$2, minecraft_version=$3, java_version=$4, server_ip=$5
|
||||||
|
WHERE id=$6`,
|
||||||
|
name, slug, minecraftVersion, javaVersion, serverIP, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file uploads if any
|
||||||
|
files := r.MultipartForm.File["files"]
|
||||||
|
if len(files) > 0 {
|
||||||
|
// Create instance directory for this modpack
|
||||||
|
uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug)
|
||||||
|
os.MkdirAll(uploadDir, 0o755)
|
||||||
|
|
||||||
|
var uploaded []string
|
||||||
|
for _, fh := range files {
|
||||||
|
src, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
src.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") {
|
||||||
|
extracted, err := h.extractZip(data, uploadDir)
|
||||||
|
if err == nil {
|
||||||
|
uploaded = append(uploaded, extracted...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
destDir := filepath.Join(h.cfg.CASDir, hash[:2])
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(destDir, hash), data, 0o644)
|
||||||
|
|
||||||
|
h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (sha1) DO NOTHING`,
|
||||||
|
hash, int64(len(data)), fh.Filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
instPath := filepath.Join(uploadDir, "mods", fh.Filename)
|
||||||
|
os.MkdirAll(filepath.Dir(instPath), 0o755)
|
||||||
|
os.WriteFile(instPath, data, 0o644)
|
||||||
|
|
||||||
|
uploaded = append(uploaded, fh.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Optionally generate manifest after upload
|
||||||
|
// For now, just return success with upload info
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "updated",
|
||||||
|
"uploaded": uploaded,
|
||||||
|
"count": len(uploaded),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No files uploaded, just updated modpack
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteModpack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid modpack ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE modpacks SET is_active = false WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Delete failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Upload ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if slug == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Modpack slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM modpacks WHERE slug = $1 AND is_active = true)`, slug,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil || !exists {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "Modpack not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := r.MultipartForm.File["files"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "No files uploaded (field name: 'files')")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug)
|
||||||
|
var uploaded []string
|
||||||
|
|
||||||
|
for _, fh := range files {
|
||||||
|
src, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
src.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") {
|
||||||
|
extracted, err := h.extractZip(data, uploadDir)
|
||||||
|
if err == nil {
|
||||||
|
uploaded = append(uploaded, extracted...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
destDir := filepath.Join(h.cfg.CASDir, hash[:2])
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(destDir, hash), data, 0o644)
|
||||||
|
|
||||||
|
h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (sha1) DO NOTHING`,
|
||||||
|
hash, int64(len(data)), fh.Filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
instPath := filepath.Join(uploadDir, "mods", fh.Filename)
|
||||||
|
os.MkdirAll(filepath.Dir(instPath), 0o755)
|
||||||
|
os.WriteFile(instPath, data, 0o644)
|
||||||
|
|
||||||
|
uploaded = append(uploaded, fh.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "uploaded",
|
||||||
|
"files": uploaded,
|
||||||
|
"count": len(uploaded),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) extractZip(data []byte, dest string) ([]string, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extracted []string
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.FileInfo().IsDir() || strings.Contains(f.Name, "..") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(dest, f.Name)
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll(filepath.Dir(target), 0o755)
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(out, rc)
|
||||||
|
out.Close()
|
||||||
|
rc.Close()
|
||||||
|
|
||||||
|
extracted = append(extracted, f.Name)
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest Generation ───────────────────────────────────────
|
||||||
|
|
||||||
|
type manifestRequest struct {
|
||||||
|
MainClass string `json:"mainClass"`
|
||||||
|
JVMArgs []string `json:"jvmArgs"`
|
||||||
|
GameArgs []string `json:"gameArgs"`
|
||||||
|
AuthLibInjector string `json:"authLibInjector"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestResponse struct {
|
||||||
|
MinecraftVersion string `json:"minecraft_version"`
|
||||||
|
JavaVersion int `json:"java_version"`
|
||||||
|
ServerInfo manifestServer `json:"server_info"`
|
||||||
|
Files []manifestFile `json:"files"`
|
||||||
|
Launch manifestLaunch `json:"launch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestServer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestLaunch struct {
|
||||||
|
MainClass string `json:"mainClass"`
|
||||||
|
JVMArgs []string `json:"jvmArgs"`
|
||||||
|
GameArgs []string `json:"gameArgs"`
|
||||||
|
AuthLibInjector string `json:"authLibInjector"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if slug == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Modpack slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req manifestRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp database.Modpack
|
||||||
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT id, name, minecraft_version, java_version, server_ip FROM modpacks
|
||||||
|
WHERE slug = $1 AND is_active = true`, slug,
|
||||||
|
).Scan(&mp.ID, &mp.Name, &mp.MinecraftVersion, &mp.JavaVersion, &mp.ServerIP)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "Modpack not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug)
|
||||||
|
var files []manifestFile
|
||||||
|
|
||||||
|
filepath.Walk(instanceDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(instanceDir, path)
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
|
||||||
|
casDir := filepath.Join(h.cfg.CASDir, hash[:2])
|
||||||
|
os.MkdirAll(casDir, 0o755)
|
||||||
|
casPath := filepath.Join(casDir, hash)
|
||||||
|
if _, err := os.Stat(casPath); os.IsNotExist(err) {
|
||||||
|
os.WriteFile(casPath, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (sha1) DO NOTHING`,
|
||||||
|
hash, info.Size(), filepath.Base(rel),
|
||||||
|
)
|
||||||
|
|
||||||
|
files = append(files, manifestFile{
|
||||||
|
Path: rel,
|
||||||
|
Hash: hash,
|
||||||
|
Size: info.Size(),
|
||||||
|
URL: fmt.Sprintf("%s/files/%s", h.cfg.BaseURL, hash),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest := manifestResponse{
|
||||||
|
MinecraftVersion: mp.MinecraftVersion,
|
||||||
|
JavaVersion: mp.JavaVersion,
|
||||||
|
ServerInfo: manifestServer{Name: mp.Name, IP: mp.ServerIP},
|
||||||
|
Files: files,
|
||||||
|
Launch: manifestLaunch{
|
||||||
|
MainClass: req.MainClass,
|
||||||
|
JVMArgs: req.JVMArgs,
|
||||||
|
GameArgs: req.GameArgs,
|
||||||
|
AuthLibInjector: req.AuthLibInjector,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestDir := filepath.Join(h.cfg.CASDir, "..", "manifests", slug)
|
||||||
|
os.MkdirAll(manifestDir, 0o755)
|
||||||
|
data, _ := json.MarshalIndent(manifest, "", " ")
|
||||||
|
os.WriteFile(filepath.Join(manifestDir, "manifest.json"), data, 0o644)
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Launcher Release (CI/CD) ──────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 100 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version := r.FormValue("version")
|
||||||
|
osParam := r.FormValue("os")
|
||||||
|
arch := r.FormValue("arch")
|
||||||
|
sha256 := r.FormValue("sha256")
|
||||||
|
|
||||||
|
if version == "" || osParam == "" || arch == "" || sha256 == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "version, os, arch, and sha256 are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "No file uploaded (field name: 'file')")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
destDir := filepath.Join(h.cfg.CASDir, "..", "launcher", version, osParam, arch)
|
||||||
|
os.MkdirAll(destDir, 0o755)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Cannot read uploaded file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := utils.SHA256Bytes(data); got != sha256 {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, fmt.Sprintf("SHA-256 mismatch: expected %s, got %s", sha256, got))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := filepath.Join(destDir, header.Filename)
|
||||||
|
if err := os.WriteFile(dest, data, 0o755); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to store binary")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO launcher_releases (version, os, arch, sha256, file_path, is_active, is_mandatory)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, true, true)`,
|
||||||
|
version, osParam, arch, sha256, dest,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to register release")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, map[string]string{
|
||||||
|
"status": "released",
|
||||||
|
"version": version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,639 @@
|
|||||||
// Package api implements public HTTP endpoints for the launcher and website.
|
// package api implements public HTTP endpoints for the launcher and website.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Max skin file size: 1 MB (Minecraft skins are ~2-10 KB).
|
||||||
|
const maxSkinSize = 1 << 20
|
||||||
|
|
||||||
|
// Max cape file size: 2 MB.
|
||||||
|
const maxCapeSize = 2 << 20
|
||||||
|
|
||||||
|
// Valid Minecraft skin dimensions.
|
||||||
|
var skinDims = []struct {
|
||||||
|
W, H int
|
||||||
|
}{
|
||||||
|
{64, 32}, // legacy (pre-1.8)
|
||||||
|
{64, 64}, // modern
|
||||||
|
{128, 128},
|
||||||
|
{128, 64},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves public API endpoints.
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new API handler.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
return &Handler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the API endpoints.
|
||||||
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
// Website endpoints.
|
||||||
|
mux.HandleFunc("POST /api/web/register", h.register)
|
||||||
|
mux.HandleFunc("POST /api/web/login", h.webLogin)
|
||||||
|
mux.HandleFunc("GET /api/web/me", h.webMe)
|
||||||
|
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin)
|
||||||
|
mux.HandleFunc("POST /api/web/profile/cape", h.uploadCape)
|
||||||
|
mux.HandleFunc("DELETE /api/web/profile/skin", h.deleteSkin)
|
||||||
|
mux.HandleFunc("DELETE /api/web/profile/cape", h.deleteCape)
|
||||||
|
|
||||||
|
// Launcher endpoints.
|
||||||
|
mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest)
|
||||||
|
mux.HandleFunc("GET /api/servers.json", h.serversList)
|
||||||
|
mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest)
|
||||||
|
|
||||||
|
// Profile — public read.
|
||||||
|
mux.HandleFunc("GET /api/web/profile/{uuid}", h.getProfile)
|
||||||
|
|
||||||
|
// Skin serving.
|
||||||
|
mux.HandleFunc("GET /skins/{hash}", h.serveSkin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request / Response types ──────────────────────────────────
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerResponse struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webLoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webLoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serversResponse struct {
|
||||||
|
Servers []serverInfo `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverInfo struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type launcherLatestResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Downloads map[string]string `json:"downloads"` // os+arch -> url
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
IsMandatory bool `json:"is_mandatory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileResponse struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Textures *textureInfo `json:"textures,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type textureInfo struct {
|
||||||
|
SkinHash string `json:"skin_hash,omitempty"`
|
||||||
|
CapeHash string `json:"cape_hash,omitempty"`
|
||||||
|
IsSlim bool `json:"is_slim"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mojang session server profile response (for game client).
|
||||||
|
type sessionProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Props []sessionProfileProp `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionProfileProp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req registerRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Email == "" || req.Password == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Username, email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation (RFC 5321).
|
||||||
|
if len(req.Email) > 254 {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Email too long (max 254 characters)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(req.Email); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uniqueness.
|
||||||
|
var exists int
|
||||||
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT COUNT(*) FROM users WHERE username = $1 OR email = $2`,
|
||||||
|
req.Username, req.Email,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists > 0 {
|
||||||
|
utils.WriteError(w, http.StatusConflict, "Username or email already taken")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid := auth.GenerateUUID()
|
||||||
|
passwordHash, err := auth.HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to hash password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO users (username, email, password_hash, uuid, role)
|
||||||
|
VALUES ($1, $2, $3, $4, 'user')`,
|
||||||
|
req.Username, req.Email, passwordHash, uuid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusCreated, registerResponse{UUID: uuid})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req webLoginRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
utils.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
|
||||||
|
WHERE username = $1 OR email = $1`,
|
||||||
|
req.Username,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID)
|
||||||
|
|
||||||
|
if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
|
utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple session token (for web; launcher uses Yggdrasil).
|
||||||
|
token := auth.GenerateToken()
|
||||||
|
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO yggdrasil_sessions (client_token, access_token, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`,
|
||||||
|
token, token, user.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set authentication token in cookie for web frontend
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: r.TLS != nil, // Set secure flag if HTTPS
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, webLoginResponse{
|
||||||
|
Token: token,
|
||||||
|
UUID: user.UUID,
|
||||||
|
Username: user.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxSkinSize)
|
||||||
|
if err := r.ParseMultipartForm(maxSkinSize); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("skin")
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "No skin file provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot read skin file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidSkinPNG(data) {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
|
||||||
|
// Store in CAS.
|
||||||
|
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to store skin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dest := filepath.Join(destDir, hash+".png")
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to write skin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user profile.
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO player_textures (user_id, skin_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET skin_hash = $2`,
|
||||||
|
userID, hash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"hash": hash})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxCapeSize)
|
||||||
|
if err := r.ParseMultipartForm(maxCapeSize); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("cape")
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "No cape file provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Cannot read cape file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate PNG header.
|
||||||
|
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Invalid PNG file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
|
||||||
|
// Store in skins dir alongside skins (same CAS layout).
|
||||||
|
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to store cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dest := filepath.Join(destDir, hash+".png")
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to write cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user profile.
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO player_textures (user_id, cape_hash)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET cape_hash = $2`,
|
||||||
|
userID, hash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"hash": hash})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE player_textures SET skin_hash = NULL WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to remove skin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "skin removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE player_textures SET cape_hash = NULL WHERE user_id = $1`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Failed to remove cape")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "cape removed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticateRequest extracts and validates the Bearer token from the request,
|
||||||
|
// returning the user ID on success or 0 on failure (having already written the error).
|
||||||
|
func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) int {
|
||||||
|
token := auth.ExtractBearer(r.Header.Get("Authorization"))
|
||||||
|
if token == "" {
|
||||||
|
utils.WriteError(w, http.StatusUnauthorized, "Missing authorization token")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT user_id FROM yggdrasil_sessions
|
||||||
|
WHERE access_token = $1 AND expires_at > NOW()`,
|
||||||
|
token,
|
||||||
|
).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusUnauthorized, "Invalid or expired token")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// webMe returns the current authenticated user's info (for checking admin status in UI).
|
||||||
|
func (h *Handler) webMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := h.authenticateRequest(w, r)
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var username, uuid, role string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT username, uuid, role FROM users WHERE id = $1`, userID,
|
||||||
|
).Scan(&username, &uuid, &role)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"username": username,
|
||||||
|
"uuid": uuid,
|
||||||
|
"role": role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
osParam := r.URL.Query().Get("os")
|
||||||
|
archParam := r.URL.Query().Get("arch")
|
||||||
|
|
||||||
|
if osParam == "" || archParam == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "os and arch query parameters are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var release database.LauncherRelease
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT version, file_path, sha256, is_mandatory FROM launcher_releases
|
||||||
|
WHERE os = $1 AND arch = $2 AND is_active = true
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
osParam, archParam,
|
||||||
|
).Scan(&release.Version, &release.FilePath, &release.SHA256, &release.IsMandatory)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "No release found for this platform")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s/%s",
|
||||||
|
h.cfg.BaseURL, release.Version, osParam, archParam, filepath.Base(release.FilePath))
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, launcherLatestResponse{
|
||||||
|
Version: release.Version,
|
||||||
|
Downloads: map[string]string{osParam + "_" + archParam: downloadURL},
|
||||||
|
SHA256: release.SHA256,
|
||||||
|
IsMandatory: release.IsMandatory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serversList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.db.Pool().Query(r.Context(),
|
||||||
|
`SELECT slug, name, minecraft_version, server_ip FROM modpacks
|
||||||
|
WHERE is_active = true ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusInternalServerError, "Database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []serverInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var s serverInfo
|
||||||
|
if err := rows.Scan(&s.Slug, &s.Name, &s.Version, &s.IP); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, serversResponse{Servers: servers})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("slug")
|
||||||
|
if slug == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "Instance slug is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check modpack exists.
|
||||||
|
var exists bool
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM modpacks WHERE slug = $1 AND is_active = true)`,
|
||||||
|
slug,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil || !exists {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "Instance not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest file.
|
||||||
|
manifestPath := filepath.Join(h.cfg.CASDir, "..", "manifests", slug, "manifest.json")
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "Manifest not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uuid := r.PathValue("uuid")
|
||||||
|
if uuid == "" {
|
||||||
|
utils.WriteError(w, http.StatusBadRequest, "UUID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p profileResponse
|
||||||
|
var skinHash, capeHash *string
|
||||||
|
var isSlim bool
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.uuid, u.username, pt.skin_hash, pt.cape_hash, COALESCE(pt.is_slim, false)
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN player_textures pt ON pt.user_id = u.id
|
||||||
|
WHERE u.uuid = $1`,
|
||||||
|
uuid,
|
||||||
|
).Scan(&p.UUID, &p.Username, &skinHash, &capeHash, &isSlim)
|
||||||
|
if err != nil {
|
||||||
|
utils.WriteError(w, http.StatusNotFound, "Player not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if skinHash != nil || capeHash != nil {
|
||||||
|
p.Textures = &textureInfo{
|
||||||
|
SkinHash: deref(skinHash),
|
||||||
|
CapeHash: deref(capeHash),
|
||||||
|
IsSlim: isSlim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := r.PathValue("hash")
|
||||||
|
if hash == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize: only hex chars.
|
||||||
|
for _, c := range hash {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(h.cfg.SkinsDir, hash[:2], hash+".png")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// isValidSkinPNG checks that data is a valid PNG with accepted Minecraft skin dimensions.
|
||||||
|
func isValidSkinPNG(data []byte) bool {
|
||||||
|
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := png.DecodeConfig(strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range skinDims {
|
||||||
|
if cfg.Width == d.W && cfg.Height == d.H {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,434 @@
|
|||||||
// Package auth implements Yggdrasil authentication protocol.
|
// package auth implements the Yggdrasil authentication protocol.
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves Yggdrasil endpoints.
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new auth handler.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
return &Handler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the Yggdrasil endpoints on the given mux.
|
||||||
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /authserver/authenticate", h.authenticate)
|
||||||
|
mux.HandleFunc("POST /authserver/refresh", h.refresh)
|
||||||
|
mux.HandleFunc("POST /authserver/validate", h.validate)
|
||||||
|
mux.HandleFunc("POST /authserver/invalidate", h.invalidate)
|
||||||
|
mux.HandleFunc("POST /authserver/signout", h.signout)
|
||||||
|
|
||||||
|
// Session server — game client queries player skins/profile.
|
||||||
|
// The "unsigned" variant is controlled via ?unsigned=true query parameter.
|
||||||
|
mux.HandleFunc("GET /sessionserver/session/minecraft/profile/{uuid}", h.sessionProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request / Response types ──────────────────────────────────
|
||||||
|
|
||||||
|
type authenticateRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type authenticateResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientToken string `json:"clientToken"`
|
||||||
|
AvailableProfile []profile `json:"availableProfiles"`
|
||||||
|
SelectedProfile *profile `json:"selectedProfile,omitempty"`
|
||||||
|
User *userProperties `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type profile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userProperties struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Properties []property `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type property struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type refreshRequest struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientToken string `json:"clientToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type refreshResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientToken string `json:"clientToken"`
|
||||||
|
SelectedProfile *profile `json:"selectedProfile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session server types (Mojang-compatible).
|
||||||
|
type sessionProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Props []sessionProfileProp `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionProfileProp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Signature string `json:"signature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req authenticateRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user by username or email.
|
||||||
|
user, err := h.findUser(r.Context(), req.Username)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password (SHA-256 hex comparison).
|
||||||
|
if !VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens.
|
||||||
|
accessToken := GenerateToken()
|
||||||
|
clientToken := GenerateToken()
|
||||||
|
|
||||||
|
// Store session.
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`INSERT INTO yggdrasil_sessions (client_token, access_token, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
clientToken, accessToken, user.ID, expiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := authenticateResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
ClientToken: clientToken,
|
||||||
|
SelectedProfile: &profile{
|
||||||
|
ID: user.UUID,
|
||||||
|
Name: user.Username,
|
||||||
|
},
|
||||||
|
AvailableProfile: []profile{{
|
||||||
|
ID: user.UUID,
|
||||||
|
Name: user.Username,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Bad Request", "Cannot read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refreshRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Bad Request", "Invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up session.
|
||||||
|
var userID int
|
||||||
|
var expiresAt time.Time
|
||||||
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT user_id, expires_at FROM yggdrasil_sessions
|
||||||
|
WHERE access_token = $1 AND client_token = $2`,
|
||||||
|
req.AccessToken, req.ClientToken,
|
||||||
|
).Scan(&userID, &expiresAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Forbidden", "Token expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate access token.
|
||||||
|
newAccessToken := GenerateToken()
|
||||||
|
_, err = h.db.Pool().Exec(r.Context(),
|
||||||
|
`UPDATE yggdrasil_sessions SET access_token = $1, expires_at = $2
|
||||||
|
WHERE access_token = $3`,
|
||||||
|
newAccessToken, time.Now().Add(24*time.Hour), req.AccessToken,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Internal Error", "Failed to refresh")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info.
|
||||||
|
var username, uuid string
|
||||||
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT username, uuid FROM users WHERE id = $1`, userID,
|
||||||
|
).Scan(&username, &uuid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Internal Error", "User not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, refreshResponse{
|
||||||
|
AccessToken: newAccessToken,
|
||||||
|
ClientToken: req.ClientToken,
|
||||||
|
SelectedProfile: &profile{
|
||||||
|
ID: uuid,
|
||||||
|
Name: username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refreshRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
err = h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT expires_at FROM yggdrasil_sessions
|
||||||
|
WHERE access_token = $1 AND client_token = $2`,
|
||||||
|
req.AccessToken, req.ClientToken,
|
||||||
|
).Scan(&expiresAt)
|
||||||
|
|
||||||
|
if err != nil || time.Now().After(expiresAt) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) invalidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refreshRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = h.db.Pool().Exec(r.Context(),
|
||||||
|
`DELETE FROM yggdrasil_sessions WHERE access_token = $1 AND client_token = $2`,
|
||||||
|
req.AccessToken, req.ClientToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) signout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.findUser(r.Context(), req.Username)
|
||||||
|
if err != nil || !VerifyPassword(req.Password, user.PasswordHash) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = h.db.Pool().Exec(r.Context(),
|
||||||
|
`DELETE FROM yggdrasil_sessions WHERE user_id = $1`, user.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionProfile returns the Mojang-compatible profile for the game client.
|
||||||
|
// URL: GET /sessionserver/session/minecraft/profile/{uuid}
|
||||||
|
// The game client uses this to look up player textures (skin + cape).
|
||||||
|
func (h *Handler) sessionProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uuid := r.PathValue("uuid")
|
||||||
|
if uuid == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsigned=true → omit signature (used by some clients).
|
||||||
|
_ = r.URL.Query().Get("unsigned")
|
||||||
|
|
||||||
|
// Look up user + textures by UUID.
|
||||||
|
var username string
|
||||||
|
var skinHash, capeHash *string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.username, pt.skin_hash, pt.cape_hash
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN player_textures pt ON pt.user_id = u.id
|
||||||
|
WHERE u.uuid = $1`,
|
||||||
|
uuid,
|
||||||
|
).Scan(&username, &skinHash, &capeHash)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build texture URL prefix.
|
||||||
|
textureBase := h.cfg.BaseURL + "/skins/"
|
||||||
|
|
||||||
|
// Build properties (Mojang format).
|
||||||
|
props := make([]sessionProfileProp, 0, 1)
|
||||||
|
texObj := make(map[string]string)
|
||||||
|
if skinHash != nil && *skinHash != "" {
|
||||||
|
texObj["skin"] = textureBase + *skinHash + ".png"
|
||||||
|
}
|
||||||
|
if capeHash != nil && *capeHash != "" {
|
||||||
|
texObj["cape"] = textureBase + *capeHash + ".png"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(texObj) > 0 {
|
||||||
|
texJSON, _ := json.Marshal(texObj)
|
||||||
|
props = append(props, sessionProfileProp{
|
||||||
|
Name: "textures",
|
||||||
|
Value: string(texJSON),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prof := sessionProfileResponse{
|
||||||
|
ID: uuid,
|
||||||
|
Name: username,
|
||||||
|
Props: props,
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.WriteJSON(w, http.StatusOK, prof)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) {
|
||||||
|
var user database.User
|
||||||
|
err := h.db.Pool().QueryRow(ctx,
|
||||||
|
`SELECT id, username, email, password_hash, uuid, role FROM users
|
||||||
|
WHERE username = $1 OR email = $1`, login,
|
||||||
|
).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.UUID, &user.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractBearer extracts the token from "Authorization: Bearer <token>" header.
|
||||||
|
func ExtractBearer(h string) string {
|
||||||
|
if strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return h[7:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword checks a plaintext password against a stored bcrypt hash.
|
||||||
|
func VerifyPassword(password, hash string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a random hex token (16 bytes → 32 hex chars).
|
||||||
|
func GenerateToken() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, err, msg string) {
|
||||||
|
utils.WriteJSON(w, status, errorResponse{
|
||||||
|
Error: err,
|
||||||
|
ErrorMessage: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword returns a bcrypt hash of the password for storage.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hashing password: %w", err)
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBcryptHash reports whether the given hash looks like a bcrypt hash
|
||||||
|
// (starts with $2a$, $2b$, or $2y$). Used to detect legacy SHA-256 hashes.
|
||||||
|
func IsBcryptHash(hash string) bool {
|
||||||
|
return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPasswordHashing is returned when bcrypt hashing fails.
|
||||||
|
var ErrPasswordHashing = errors.New("password hashing failed")
|
||||||
|
|
||||||
|
// GenerateUUID creates a random UUID v4-like string.
|
||||||
|
func GenerateUUID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80 // variant
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||||
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,230 @@
|
|||||||
// package cas implements Content-Addressable Storage for files (mods, assets, etc).
|
// package cas implements Content-Addressable Storage for immutable files.
|
||||||
|
//
|
||||||
|
// Files are stored by SHA-1 hash under <cas_dir>/<first_2_chars>/<full_hash>.
|
||||||
|
// Because files are immutable (changing content changes the hash), long cache
|
||||||
|
// headers are safe and desirable.
|
||||||
package cas
|
package cas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hashLocks provides per-hash mutexes to prevent concurrent writes
|
||||||
|
// to the same CAS entry. Protected by mu.
|
||||||
|
var (
|
||||||
|
hashLocks = make(map[string]*sync.Mutex)
|
||||||
|
hashLocksMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// acquireLock returns (and creates if needed) the mutex for a given hash
|
||||||
|
// and locks it. Caller MUST call releaseLock for the same hash.
|
||||||
|
func acquireLock(hash string) {
|
||||||
|
hashLocksMu.Lock()
|
||||||
|
mu, ok := hashLocks[hash]
|
||||||
|
if !ok {
|
||||||
|
mu = &sync.Mutex{}
|
||||||
|
hashLocks[hash] = mu
|
||||||
|
}
|
||||||
|
hashLocksMu.Unlock()
|
||||||
|
mu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseLock unlocks the per-hash mutex. Must be called after acquireLock
|
||||||
|
// to avoid deadlocks.
|
||||||
|
func releaseLock(hash string) {
|
||||||
|
hashLocksMu.Lock()
|
||||||
|
mu, ok := hashLocks[hash]
|
||||||
|
hashLocksMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new CAS handler.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
return &Handler{db: db, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the CAS endpoints.
|
||||||
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
// Public file serving — immutable, long cache.
|
||||||
|
mux.HandleFunc("GET /files/{hash}", h.serveFile)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(h.cfg.CASDir, hash[:2], hash)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLauncherAsset serves a released launcher binary.
|
||||||
|
// Path format: /files/launcher/{version}/{os}/{arch}/{filename}
|
||||||
|
func (h *Handler) serveLauncherAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
version := r.PathValue("version")
|
||||||
|
osParam := r.PathValue("os")
|
||||||
|
arch := r.PathValue("arch")
|
||||||
|
filename := r.PathValue("filename")
|
||||||
|
|
||||||
|
// Basic validation against path traversal.
|
||||||
|
for _, v := range []string{version, osParam, arch, filename} {
|
||||||
|
if strings.ContainsAny(v, "/\\") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(h.cfg.CASDir, "..", "launcher", version, osParam, arch, filename)
|
||||||
|
// Verify the resolved path is under the expected launcher storage dir.
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
expected := filepath.Clean(filepath.Join(h.cfg.CASDir, "..", "launcher"))
|
||||||
|
if !strings.HasPrefix(clean, expected+string(os.PathSeparator)) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(clean)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", detectContentType(filename))
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHash checks that a hash string is exactly 40 hex characters (SHA-1 length).
|
||||||
|
func isValidHash(hash string) bool {
|
||||||
|
if len(hash) != 40 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range hash {
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreFile writes data to the CAS directory structure.
|
||||||
|
// Returns the SHA-1 hash of the stored data.
|
||||||
|
// Uses a per-hash mutex to prevent concurrent writes of the same entry.
|
||||||
|
func StoreFile(casDir string, data []byte) (string, error) {
|
||||||
|
hash := utils.SHA1Bytes(data)
|
||||||
|
acquireLock(hash)
|
||||||
|
defer releaseLock(hash)
|
||||||
|
|
||||||
|
if FileExists(casDir, hash) {
|
||||||
|
return hash, nil // Already stored by a concurrent caller.
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(casDir, hash[:2])
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(destDir, hash)
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileExists checks if a file with the given SHA-1 hash exists in CAS.
|
||||||
|
func FileExists(casDir, hash string) bool {
|
||||||
|
path := filepath.Join(casDir, hash[:2], hash)
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAndStore writes data to CAS only if the SHA-1 matches the expected hash.
|
||||||
|
// This prevents corrupt or tampered uploads from being stored.
|
||||||
|
func VerifyAndStore(casDir string, data []byte, expectedHash string) (string, error) {
|
||||||
|
got := utils.SHA1Bytes(data)
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), []byte(expectedHash)) != 1 {
|
||||||
|
return "", ErrHashMismatch
|
||||||
|
}
|
||||||
|
if FileExists(casDir, expectedHash) {
|
||||||
|
return expectedHash, nil // Already stored; idempotent.
|
||||||
|
}
|
||||||
|
return StoreFile(casDir, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errHashMismatch = &hashMismatchError{}
|
||||||
|
|
||||||
|
// ErrHashMismatch is returned when computed hash doesn't match expected.
|
||||||
|
var ErrHashMismatch = errHashMismatch
|
||||||
|
|
||||||
|
type hashMismatchError struct{}
|
||||||
|
|
||||||
|
func (e *hashMismatchError) Error() string { return "SHA-1 hash mismatch" }
|
||||||
|
|||||||
174
internal/cas/cas_test.go
Normal file
174
internal/cas/cas_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package cas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"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 TestStoreFile_ConcurrentSameHash(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
data := []byte("concurrent write test")
|
||||||
|
|
||||||
|
const workers = 10
|
||||||
|
var success int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
hash, err := StoreFile(dir, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("StoreFile failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(hash) != 40 {
|
||||||
|
t.Errorf("invalid hash length: %d", len(hash))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.AddInt64(&success, 1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if success != workers {
|
||||||
|
t.Errorf("expected %d successes, got %d", workers, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All goroutines must produce the same hash for identical data.
|
||||||
|
h := sha1.Sum(data)
|
||||||
|
hash := hex.EncodeToString(h[:])
|
||||||
|
if !FileExists(dir, hash) {
|
||||||
|
t.Error("file not found after concurrent writes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,62 @@
|
|||||||
// package config handles server configuration (env vars, config files).
|
// package config handles server configuration from environment variables.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all server configuration.
|
||||||
|
type Config struct {
|
||||||
|
// HTTP
|
||||||
|
Port int `json:"port"`
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DatabaseURL string `json:"database_url"`
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
CASDir string `json:"cas_dir"` // Content-Addressable Storage root
|
||||||
|
SkinsDir string `json:"skins_dir"` // Uploaded skins
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
JWTSecret string `json:"jwt_secret"`
|
||||||
|
CIsecret string `json:"ci_secret"` // Token for CI/CD launcher release endpoint
|
||||||
|
|
||||||
|
// Public
|
||||||
|
BaseURL string `json:"base_url"` // External URL for CDN links
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from environment variables with sensible defaults.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
port, err := strconv.Atoi(getEnv("SERVER_PORT", "8080"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid SERVER_PORT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Port: port,
|
||||||
|
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||||
|
CASDir: getEnv("CAS_DIR", "/var/www/cdn/files"),
|
||||||
|
SkinsDir: getEnv("SKINS_DIR", "/var/www/cdn/skins"),
|
||||||
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
|
CIsecret: getEnv("CI_SECRET", ""),
|
||||||
|
BaseURL: getEnv("BASE_URL", "https://minecraft.mrixs.me"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,95 @@
|
|||||||
// package database manages PostgreSQL connections, migrations, and data models.
|
// package database manages PostgreSQL connections and data models.
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB wraps pgxpool.Pool with application-specific helpers.
|
||||||
|
type DB struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open creates a new database connection pool.
|
||||||
|
func Open(ctx context.Context, databaseURL string) (*DB, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connecting to database: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("pinging database: %w", err)
|
||||||
|
}
|
||||||
|
return &DB{pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the connection pool.
|
||||||
|
func (db *DB) Close() {
|
||||||
|
db.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool returns the underlying pool for direct queries.
|
||||||
|
func (db *DB) Pool() *pgxpool.Pool {
|
||||||
|
return db.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Models ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// User represents a registered player.
|
||||||
|
type User struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
Email string `db:"email"`
|
||||||
|
PasswordHash string `db:"password_hash"`
|
||||||
|
UUID string `db:"uuid"`
|
||||||
|
Role string `db:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// YggdrasilSession represents an active authentication session.
|
||||||
|
type YggdrasilSession struct {
|
||||||
|
ClientToken string `db:"client_token"`
|
||||||
|
AccessToken string `db:"access_token"`
|
||||||
|
UserID int `db:"user_id"`
|
||||||
|
ExpiresAt string `db:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modpack represents a game server / modpack configuration.
|
||||||
|
type Modpack struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Slug string `db:"slug"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
MinecraftVersion string `db:"minecraft_version"`
|
||||||
|
JavaVersion int `db:"java_version"`
|
||||||
|
ServerIP string `db:"server_ip"`
|
||||||
|
IsActive bool `db:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalFile is a CAS entry.
|
||||||
|
type GlobalFile struct {
|
||||||
|
SHA1 string `db:"sha1"`
|
||||||
|
Size int64 `db:"size_bytes"`
|
||||||
|
FileName string `db:"file_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerTextures holds skin/cape references for a user.
|
||||||
|
type PlayerTextures struct {
|
||||||
|
UserID int `db:"user_id"`
|
||||||
|
SkinHash string `db:"skin_hash"`
|
||||||
|
CapeHash string `db:"cape_hash"`
|
||||||
|
IsSlim bool `db:"is_slim"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LauncherRelease tracks published launcher binaries.
|
||||||
|
type LauncherRelease struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Version string `db:"version"`
|
||||||
|
OS string `db:"os"`
|
||||||
|
Arch string `db:"arch"`
|
||||||
|
SHA256 string `db:"sha256"`
|
||||||
|
FilePath string `db:"file_path"`
|
||||||
|
IsActive bool `db:"is_active"`
|
||||||
|
IsMandatory bool `db:"is_mandatory"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,174 @@
|
|||||||
// package middleware provides HTTP middleware (JWT, CORS, rate limiting, logging).
|
// package middleware provides HTTP middleware (CORS, logging, rate limiting).
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS adds permissive CORS headers for API endpoints.
|
||||||
|
// In production, restrict AllowOrigins to your actual domains.
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CI-Token")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging logs each request with method, path, status code, and duration.
|
||||||
|
func Logging(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
log.Printf("%s %s %d %s %s",
|
||||||
|
r.Method, r.RequestURI, ww.status,
|
||||||
|
time.Since(start), r.RemoteAddr,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery catches panics in downstream handlers and returns 500.
|
||||||
|
// Logs the stack trace for debugging.
|
||||||
|
func Recovery(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
log.Printf("PANIC: %v\n%s", rec, debug.Stack())
|
||||||
|
http.Error(w, `{"error":"Internal Server Error"}`, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusWriter wraps http.ResponseWriter to capture the status code.
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) WriteHeader(status int) {
|
||||||
|
w.status = status
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter implements a simple per-IP token bucket rate limiter.
|
||||||
|
// Not suitable for production behind a proxy (use a real rate limiter then),
|
||||||
|
// but sufficient for development and single-instance deployments.
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[string]*bucket
|
||||||
|
rate int // tokens per interval
|
||||||
|
interval time.Duration
|
||||||
|
burst int // max bucket size
|
||||||
|
}
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens int
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter creates a rate limiter allowing `rate` requests per `interval`,
|
||||||
|
// with a maximum burst of `burst`.
|
||||||
|
func NewRateLimiter(rate int, interval time.Duration, burst int) *RateLimiter {
|
||||||
|
rl := &RateLimiter{
|
||||||
|
clients: make(map[string]*bucket),
|
||||||
|
rate: rate,
|
||||||
|
interval: interval,
|
||||||
|
burst: burst,
|
||||||
|
}
|
||||||
|
// Periodically clean up stale entries.
|
||||||
|
go rl.cleanup()
|
||||||
|
return rl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit returns an HTTP middleware that rate-limits requests by client IP.
|
||||||
|
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := clientIP(r)
|
||||||
|
if !rl.allow(ip) {
|
||||||
|
w.Header().Set("Retry-After", "60")
|
||||||
|
http.Error(w, `{"error":"Rate limit exceeded"}`, http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) allow(ip string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
b, ok := rl.clients[ip]
|
||||||
|
if !ok {
|
||||||
|
rl.clients[ip] = &bucket{tokens: rl.burst - 1, last: time.Now()}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refill tokens based on elapsed time.
|
||||||
|
elapsed := time.Since(b.last)
|
||||||
|
refill := int(elapsed / rl.interval * time.Duration(rl.rate))
|
||||||
|
if refill > 0 {
|
||||||
|
b.tokens = min(b.tokens+refill, rl.burst)
|
||||||
|
b.last = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.tokens > 0 {
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) cleanup() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
rl.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for ip, b := range rl.clients {
|
||||||
|
if now.Sub(b.last) > 10*time.Minute {
|
||||||
|
delete(rl.clients, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
// Check X-Forwarded-For first (if behind a proxy).
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
if idx := strings.IndexByte(xff, ','); idx != -1 {
|
||||||
|
return strings.TrimSpace(xff[:idx])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(xff)
|
||||||
|
}
|
||||||
|
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||||
|
return xri
|
||||||
|
}
|
||||||
|
// Fall back to RemoteAddr (strip port).
|
||||||
|
host, _, ok := strings.Cut(r.RemoteAddr, ":")
|
||||||
|
if !ok {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
264
internal/templates/html/admin.html
Normal file
264
internal/templates/html/admin.html
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<h1>Админ-панель: Управление модпаками</h1>
|
||||||
|
|
||||||
|
<!-- Alerts -->
|
||||||
|
<div id="alert-error" class="alert alert-error"></div>
|
||||||
|
<div id="alert-success" class="alert alert-success"></div>
|
||||||
|
|
||||||
|
<!-- Modpacks List -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Список модпаков</h2>
|
||||||
|
<div id="modpacks-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Версия Minecraft</th>
|
||||||
|
<th>Версия Java</th>
|
||||||
|
<th>IP сервера</th>
|
||||||
|
<th>Активен</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="modpacks-tbody">
|
||||||
|
<!-- Will be filled by JavaScript -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unified Create & Upload Form -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Создание и загрузка модпака</h2>
|
||||||
|
<form id="modpack-form" enctype="multipart/form-data">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<label for="name">Название модпака</label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="Например: HiTech Server">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="slug">Slug (уникальный идентификатор)</label>
|
||||||
|
<input type="text" id="slug" name="slug" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="minecraft_version">Версия Minecraft</label>
|
||||||
|
<input type="text" id="minecraft_version" name="minecraft_version" required placeholder="Например: 1.21">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="java_version">Версия Java</label>
|
||||||
|
<input type="number" id="java_version" name="java_version" min="8" required value="21">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="server_ip">IP сервера</label>
|
||||||
|
<input type="text" id="server_ip" name="server_ip" placeholder="Например: play.mrixs.me:25565">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label for="files">Файлы модпака (ZIP архив или отдельные файлы)</label>
|
||||||
|
<input type="file" id="files" name="files" multiple>
|
||||||
|
<p>Поддерживаются архивы .zip ( будут распакованы автоматически) и отдельные файлы.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-progress" style="display: none; margin-top: 1rem;">
|
||||||
|
<div>Прогресс загрузки: <span id="upload-percent">0%</span></div>
|
||||||
|
<progress id="upload-bar" value="0" max="100"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Создать модпак и загрузить файлы</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Helper functions
|
||||||
|
function showAlert(error, success) {
|
||||||
|
const errorAlert = document.getElementById('alert-error');
|
||||||
|
const successAlert = document.getElementById('alert-success');
|
||||||
|
if (error) {
|
||||||
|
errorAlert.textContent = error;
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
successAlert.classList.remove('show');
|
||||||
|
} else if (success) {
|
||||||
|
successAlert.textContent = success;
|
||||||
|
successAlert.classList.add('show');
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
} else {
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
successAlert.classList.remove('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
return localStorage.getItem('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from name (transliterate and clean)
|
||||||
|
function generateSlugFromName(name) {
|
||||||
|
if (!name) return '';
|
||||||
|
|
||||||
|
// Convert to lowercase and trim
|
||||||
|
let slug = name.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Replace non-alphanumeric characters with hyphens
|
||||||
|
slug = slug.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
|
||||||
|
// Remove leading/trailing hyphens
|
||||||
|
slug = slug.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
slug = slug.substring(0, 32);
|
||||||
|
|
||||||
|
return slug || 'modpack';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and display modpacks
|
||||||
|
async function loadModpacks() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/modpacks', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch modpacks');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modpacks = await response.json();
|
||||||
|
const tbody = document.getElementById('modpacks-tbody');
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
modpacks.forEach(mp => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${mp.slug}</td>
|
||||||
|
<td>${mp.name}</td>
|
||||||
|
<td>${mp.minecraft_version}</td>
|
||||||
|
<td>${mp.java_version}</td>
|
||||||
|
<td>${mp.server_ip || '-'}</td>
|
||||||
|
<td>${mp.is_active ? 'Да' : 'Нет'}</td>
|
||||||
|
<td>
|
||||||
|
<!-- We don't have edit/delete endpoints in API yet, but we can add later -->
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="loadModpackForEdit('${mp.slug}')">Редактировать</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Ошибка загрузки модпаков: ' + err.message, null);
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unified form submission (create modpack + upload files)
|
||||||
|
document.getElementById('modpack-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const form = e.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Get values
|
||||||
|
const name = formData.get('name');
|
||||||
|
let slug = formData.get('slug');
|
||||||
|
const minecraftVersion = formData.get('minecraft_version');
|
||||||
|
const javaVersionStr = formData.get('java_version');
|
||||||
|
const serverIP = formData.get('server_ip');
|
||||||
|
const files = document.getElementById('files').files;
|
||||||
|
|
||||||
|
// Auto-generate slug from name if slug field is empty or matches name-based prediction
|
||||||
|
const predictedSlug = generateSlugFromName(name);
|
||||||
|
if (!slug || slug === predictedSlug) {
|
||||||
|
slug = predictedSlug;
|
||||||
|
document.getElementById('slug').value = slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!slug || !name || !minecraftVersion) {
|
||||||
|
showAlert('Slug, название и версия Minecraft обязательны', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse java_version
|
||||||
|
const javaVersion = javaVersionStr ? parseInt(javaVersionStr) : 8;
|
||||||
|
if (isNaN(javaVersion)) {
|
||||||
|
showAlert('Неверная версия Java', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show progress
|
||||||
|
const progressDiv = document.getElementById('upload-progress');
|
||||||
|
const percentSpan = document.getElementById('upload-percent');
|
||||||
|
const progressBar = document.getElementById('upload-bar');
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
percentSpan.textContent = '0%';
|
||||||
|
progressBar.value = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send request to create modpack with files
|
||||||
|
const response = await fetch('/api/admin/modpacks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
let message = 'Модпак успешно создан';
|
||||||
|
if result.uploaded && result.uploaded.length > 0 {
|
||||||
|
message += ` и загружено ${result.uploaded.length} файлов`;
|
||||||
|
} else if result.count && result.count > 0 {
|
||||||
|
message += ` и загружено ${result.count} файлов`;
|
||||||
|
}
|
||||||
|
showAlert(null, message);
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('slug').value = ''; // Clear slug for next time
|
||||||
|
loadModpacks(); // Refresh list
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showAlert(error.errorMessage || 'Ошибка создания модпака', null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showAlert('Ошибка сети: ' + err.message, null);
|
||||||
|
} finally {
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file input change for auto-generating slug
|
||||||
|
document.getElementById('name').addEventListener('input', function(e) {
|
||||||
|
const name = e.target.value;
|
||||||
|
const predictedSlug = generateSlugFromName(name);
|
||||||
|
|
||||||
|
// Only auto-update slug if user hasn't manually edited it
|
||||||
|
const slugInput = document.getElementById('slug');
|
||||||
|
if (!slugInput.dataset.manualEdit) {
|
||||||
|
slugInput.value = predictedSlug;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark slug as manually edited when user changes it
|
||||||
|
document.getElementById('slug').addEventListener('input', function(e) {
|
||||||
|
e.target.dataset.manualEdit = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadModpacks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
425
internal/templates/html/base.html
Normal file
425
internal/templates/html/base.html
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}} — MrixsCraft</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛏</text></svg>">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f1a;
|
||||||
|
--bg-gradient: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 100%);
|
||||||
|
--surface: #16213e;
|
||||||
|
--surface-light: #1e2d50;
|
||||||
|
--accent: #4ade80;
|
||||||
|
--accent-hover: #22c55e;
|
||||||
|
--accent-glow: rgba(74, 222, 128, 0.15);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--text-dim: #cbd5e1;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--error: #f87171;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--border: #334155;
|
||||||
|
--border-light: #475569;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg-gradient);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
|
||||||
|
a:hover { color: var(--accent-hover); text-decoration: none; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
background: rgba(22, 33, 62, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
header .hdr {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.logo span { font-size: 1.1rem; }
|
||||||
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
header nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
header nav a:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
||||||
|
header nav a.active { color: var(--accent); background: var(--accent-glow); }
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: var(--surface-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.nav-user .avatar {
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 0.7rem 1.6rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--accent-hover); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(74, 222, 128, 0.3); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { background: var(--accent-glow); box-shadow: none; }
|
||||||
|
.btn-danger {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(248, 113, 113, 0.1); }
|
||||||
|
.btn-sm { padding: 0.4rem 1rem; font-size: 0.82rem; }
|
||||||
|
.btn-lg { padding: 0.9rem 2.2rem; font-size: 1.05rem; }
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card + .card { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ── Forms ── */
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="email"], input[type="password"], input[type="file"], textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
main { flex: 1; padding: 2.5rem 1.25rem; }
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; }
|
||||||
|
.container-narrow { max-width: 440px; margin: 0 auto; }
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||||
|
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||||
|
header nav a span { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #22d3ee 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto 1.75rem;
|
||||||
|
}
|
||||||
|
.hero .actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Server Cards ── */
|
||||||
|
.server-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.server-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 32px rgba(74, 222, 128, 0.1);
|
||||||
|
}
|
||||||
|
.server-card .version-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--accent-glow);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.server-card h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
padding-right: 80px;
|
||||||
|
}
|
||||||
|
.server-card .meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.server-card .status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.server-card .status::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile ── */
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.profile-avatar {
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-light);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.profile-info h2 { margin-bottom: 0.2rem; }
|
||||||
|
.profile-info .uuid { font-size: 0.78rem; color: var(--text-muted); font-family: monospace; }
|
||||||
|
|
||||||
|
/* ── Alerts ── */
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.alert-error { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); color: var(--error); }
|
||||||
|
.alert-success { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.3); color: var(--accent); }
|
||||||
|
.alert.show { display: block; }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
footer a { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ── Misc ── */
|
||||||
|
h1 { font-size: 1.75rem; margin-bottom: 1.25rem; font-weight: 800; }
|
||||||
|
h2 { font-size: 1.3rem; margin-bottom: 1rem; font-weight: 700; }
|
||||||
|
h3 { font-size: 1.05rem; margin-bottom: 0.75rem; }
|
||||||
|
p { margin-bottom: 0.75rem; color: var(--text-muted); }
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.text-sm { font-size: 0.82rem; }
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.gap-1 { gap: 0.5rem; }
|
||||||
|
.gap-2 { gap: 1rem; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
ol, ul { margin-left: 1.25rem; color: var(--text-muted); line-height: 1.9; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="hdr">
|
||||||
|
<a href="/" class="logo"><span>⛏</span> MrixsCraft</a>
|
||||||
|
<nav id="mainNav">
|
||||||
|
<a href="/" id="nav-home">Главная</a>
|
||||||
|
<a href="/login" id="nav-login">Вход</a>
|
||||||
|
<a href="/register" id="nav-register">Регистрация</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main><div class="container">{{template "content" .}}</div></main>
|
||||||
|
<footer>
|
||||||
|
<p>MrixsCraft Server © 2026 · <a href="https://gitea.mrixs.me/Mrixs/MrixsCraft">Gitea</a></p>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
// Update nav based on auth state
|
||||||
|
(function() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const username = localStorage.getItem('username');
|
||||||
|
const nav = document.getElementById('mainNav');
|
||||||
|
if (token && username) {
|
||||||
|
// Fetch user info to check role
|
||||||
|
fetch('/api/web/me', {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Not authenticated');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
let adminLink = '';
|
||||||
|
if (data.role === 'admin') {
|
||||||
|
adminLink = '<a href="/admin" id="nav-admin" class="btn btn-sm" style="background:var(--warning);color:#000">Админ-панель</a>';
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.innerHTML =
|
||||||
|
'<a href="/" id="nav-home">Главная</a>' +
|
||||||
|
'<a href="/profile" id="nav-profile">Профиль</a>' +
|
||||||
|
adminLink +
|
||||||
|
'<div class="nav-user"><div class="avatar"></div>' + escapeHtml(username) + '</div>' +
|
||||||
|
'<a href="#" id="nav-logout" class="btn btn-sm btn-danger" style="padding:0.35rem 0.75rem">Выйти</a>';
|
||||||
|
|
||||||
|
document.getElementById('nav-logout').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('uuid');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight active nav link
|
||||||
|
const path = window.location.pathname;
|
||||||
|
document.querySelectorAll('header nav a').forEach(function(a) {
|
||||||
|
if (a.getAttribute('href') === path) a.classList.add('active');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
// If we can't fetch user info, still show basic nav
|
||||||
|
nav.innerHTML =
|
||||||
|
'<a href="/" id="nav-home">Главная</a>' +
|
||||||
|
'<a href="/profile" id="nav-profile">Профиль</a>' +
|
||||||
|
'<div class="nav-user"><div class="avatar"></div>' + escapeHtml(username) + '</div>' +
|
||||||
|
'<a href="#" id="nav-logout" class="btn btn-sm btn-danger" style="padding:0.35rem 0.75rem">Выйти</a>';
|
||||||
|
document.getElementById('nav-logout').addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('uuid');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight active nav link
|
||||||
|
const path = window.location.pathname;
|
||||||
|
document.querySelectorAll('header nav a').forEach(function(a) {
|
||||||
|
if (a.getAttribute('href') === path) a.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
73
internal/templates/html/index.html
Normal file
73
internal/templates/html/index.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="hero">
|
||||||
|
<h1>⛏ MrixsCraft</h1>
|
||||||
|
<p>Приватный Minecraft-сервер с модпаками. Зарегистрируйся, скачай лаунчер и играй с друзьями.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/register" class="btn btn-lg">Начать играть</a>
|
||||||
|
<a href="#servers" class="btn btn-outline btn-lg">Список серверов</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Servers -->
|
||||||
|
<h2 class="section-title" id="servers">🖥 Наши серверы</h2>
|
||||||
|
<div class="grid-2" id="serversGrid">
|
||||||
|
<div class="server-card">
|
||||||
|
<span class="version-badge">1.21</span>
|
||||||
|
<h3>HiTech</h3>
|
||||||
|
<p class="meta">Технический модпак · Java 21</p>
|
||||||
|
<p class="muted text-sm">Индустриальная автоматизация, технологии и прогресс. Строй фабрики, автоматизируй производство, покоряй энергию.</p>
|
||||||
|
<div class="status mt-2">Онлайн</div>
|
||||||
|
</div>
|
||||||
|
<div class="server-card">
|
||||||
|
<span class="version-badge">1.20</span>
|
||||||
|
<h3>Vanilla</h3>
|
||||||
|
<p class="meta">Ванильный сервер · Java 17</p>
|
||||||
|
<p class="muted text-sm">Классический Minecraft без модов. Строи, исследуй, выживай — всё как в старые добрые времена.</p>
|
||||||
|
<div class="status mt-2">Онлайн</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How to start -->
|
||||||
|
<h2 class="section-title mt-2">🚀 Как начать</h2>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">📝</div>
|
||||||
|
<h3>1. Регистрация</h3>
|
||||||
|
<p>Создай аккаунт на сайте. Придумай никнейм и пароль.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">💾</div>
|
||||||
|
<h3>2. Скачай лаунчер</h3>
|
||||||
|
<p>Скачай лаунчер для своей ОС. Он сам скачает все нужные файлы.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem">🎮</div>
|
||||||
|
<h3>3. Играй</h3>
|
||||||
|
<p>Авторизуйся в лаунчере, выбери сервер и нажимай PLAY.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<h2 class="section-title mt-2">✨ Возможности</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="grid-2" style="gap:1.5rem">
|
||||||
|
<div>
|
||||||
|
<h3>🔐 Своя авторизация</h3>
|
||||||
|
<p>Полноценная Yggdrasil-совместимая система. Скины, плащи, профили — всё работает.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>📦 Модпаки</h3>
|
||||||
|
<p>Готовые наборы модов с автообновлением. Лаунчер сам скачает и проверит все файлы.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>🌐 Веб-профиль</h3>
|
||||||
|
<p>Управляй скином и плащом прямо на сайте. Загружай PNG и они сразу в игре.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>🔄 Автообновление</h3>
|
||||||
|
<p>Лаунчер обновляется сам. Серверные модпаки тоже подтягиваются автоматически.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
61
internal/templates/html/login.html
Normal file
61
internal/templates/html/login.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="container-narrow">
|
||||||
|
<div class="card text-center">
|
||||||
|
<h1 style="margin-bottom:0.5rem">👋 С возвращением</h1>
|
||||||
|
<p class="muted mb-0">Войди в аккаунт, чтобы управлять профилем и скачать лаунчер.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="alert" class="alert alert-error"></div>
|
||||||
|
<form id="loginForm" novalidate>
|
||||||
|
<label for="username">Логин или Email</label>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username" placeholder="player или player@email.com">
|
||||||
|
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width:100%">Войти</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-2 muted">Нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const alert = document.getElementById('alert');
|
||||||
|
alert.className = 'alert alert-error';
|
||||||
|
alert.classList.remove('show');
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
alert.textContent = 'Заполните все поля';
|
||||||
|
alert.classList.add('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username, password})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.textContent = data.error || 'Ошибка входа';
|
||||||
|
alert.classList.add('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('uuid', data.uuid);
|
||||||
|
localStorage.setItem('username', data.username);
|
||||||
|
window.location.href = '/profile';
|
||||||
|
} catch (err) {
|
||||||
|
alert.textContent = 'Ошибка сети. Попробуй ещё раз.';
|
||||||
|
alert.classList.add('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
249
internal/templates/html/profile.html
Normal file
249
internal/templates/html/profile.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div id="authCheck" class="card text-center">
|
||||||
|
<p class="muted">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="profileContent" style="display:none">
|
||||||
|
<!-- Profile header -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="profile-header">
|
||||||
|
<img class="profile-avatar" id="skinPreview" src="" alt="Аватар" onerror="this.src='data:image/svg+xml,<svg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 8 8%27><rect fill=%27%23334155%27 width=%278%27 height=%278%27/></svg>'">
|
||||||
|
<div class="profile-info">
|
||||||
|
<h2 id="profUsername">—</h2>
|
||||||
|
<p class="uuid" id="profUuid">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skin & Cape -->
|
||||||
|
<h2 class="section-title">👤 Скин и плащ</h2>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Скин</h3>
|
||||||
|
<div id="currentSkin" class="text-center" style="margin-bottom:1rem">
|
||||||
|
<img id="skinImg" src="" alt="Скин" style="width:120px;height:160px;image-rendering:pixelated;background:var(--surface-light);border-radius:var(--radius-sm)">
|
||||||
|
<p class="muted text-sm mt-1" id="noSkinMsg">Скин не загружен</p>
|
||||||
|
</div>
|
||||||
|
<div id="alertSkin" class="alert"></div>
|
||||||
|
<form id="skinForm" enctype="multipart/form-data">
|
||||||
|
<label for="skinFile">Загрузить скин (PNG, 64×64 или 64×32)</label>
|
||||||
|
<input type="file" id="skinFile" name="file" accept=".png">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" class="btn btn-sm">Загрузить</button>
|
||||||
|
<button type="button" id="deleteSkin" class="btn btn-sm btn-danger">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Плащ</h3>
|
||||||
|
<div id="currentCape" class="text-center" style="margin-bottom:1rem">
|
||||||
|
<img id="capeImg" src="" alt="Плащ" style="width:100px;height:160px;image-rendering:pixelated;background:var(--surface-light);border-radius:var(--radius-sm)">
|
||||||
|
<p class="muted text-sm mt-1" id="noCapeMsg">Плащ не загружен</p>
|
||||||
|
</div>
|
||||||
|
<div id="alertCape" class="alert"></div>
|
||||||
|
<form id="capeForm" enctype="multipart/form-data">
|
||||||
|
<label for="capeFile">Загрузить плащ (PNG, 64×32)</label>
|
||||||
|
<input type="file" id="capeFile" name="file" accept=".png">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button type="submit" class="btn btn-sm">Загрузить</button>
|
||||||
|
<button type="button" id="deleteCape" class="btn btn-sm btn-danger">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Launcher download -->
|
||||||
|
<h2 class="section-title">💾 Лаунчер</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">Скачай лаунчер для своей операционной системы. Он автоматически загрузит все файлы модпаков.</p>
|
||||||
|
<div class="flex gap-1 mt-2" id="launcherLinks">
|
||||||
|
<a href="#" class="btn btn-sm" id="dl-win">🪟 Windows</a>
|
||||||
|
<a href="#" class="btn btn-sm" id="dl-mac">🍎 macOS</a>
|
||||||
|
<a href="#" class="btn btn-sm" id="dl-lin">🐧 Linux</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm muted mt-2" id="latestVersion">Последняя версия: —</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const savedUuid = localStorage.getItem('uuid');
|
||||||
|
const savedUsername = localStorage.getItem('username');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('authCheck').innerHTML = '<h2>🔒 Требуется авторизация</h2><p class="muted">Войди в аккаунт, чтобы увидеть профиль.</p><p class="mt-2"><a href="/login" class="btn">Войти</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('authCheck').style.display = 'none';
|
||||||
|
document.getElementById('profileContent').style.display = 'block';
|
||||||
|
|
||||||
|
// Load profile
|
||||||
|
loadProfile();
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/profile/' + savedUuid, {
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('profUsername').textContent = data.username || savedUsername || '—';
|
||||||
|
document.getElementById('profUuid').textContent = data.uuid || savedUuid || '—';
|
||||||
|
|
||||||
|
// Skin
|
||||||
|
if (data.textures && data.textures.skin_hash) {
|
||||||
|
const skinImg = document.getElementById('skinImg');
|
||||||
|
const noSkinMsg = document.getElementById('noSkinMsg');
|
||||||
|
skinImg.onerror = function() { this.style.display = 'none'; noSkinMsg.style.display = 'block'; };
|
||||||
|
skinImg.onload = function() { this.style.display = 'inline'; noSkinMsg.style.display = 'none'; };
|
||||||
|
skinImg.src = '/skins/' + data.textures.skin_hash;
|
||||||
|
} else {
|
||||||
|
document.getElementById('skinImg').style.display = 'none';
|
||||||
|
document.getElementById('noSkinMsg').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cape
|
||||||
|
if (data.textures && data.textures.cape_hash) {
|
||||||
|
const capeImg = document.getElementById('capeImg');
|
||||||
|
const noCapeMsg = document.getElementById('noCapeMsg');
|
||||||
|
capeImg.onerror = function() { this.style.display = 'none'; noCapeMsg.style.display = 'block'; };
|
||||||
|
capeImg.onload = function() { this.style.display = 'inline'; noCapeMsg.style.display = 'none'; };
|
||||||
|
capeImg.src = '/skins/' + data.textures.cape_hash;
|
||||||
|
} else {
|
||||||
|
document.getElementById('capeImg').style.display = 'none';
|
||||||
|
document.getElementById('noCapeMsg').style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Profile load error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load latest launcher version
|
||||||
|
loadLauncherInfo();
|
||||||
|
|
||||||
|
async function loadLauncherInfo() {
|
||||||
|
try {
|
||||||
|
const os = navigator.userAgent.includes('Mac') ? 'darwin' :
|
||||||
|
navigator.userAgent.includes('Win') ? 'windows' : 'linux';
|
||||||
|
const arch = navigator.userAgent.includes('x86_64') || navigator.userAgent.includes('WOW64') ? 'amd64' :
|
||||||
|
navigator.userAgent.includes('arm64') || navigator.userAgent.includes('aarch64') ? 'arm64' : 'amd64';
|
||||||
|
|
||||||
|
const res = await fetch('/api/launcher/latest?os=' + os + '&arch=' + arch);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('latestVersion').textContent = 'Последняя версия: ' + (data.version || '—');
|
||||||
|
if (data.downloads) {
|
||||||
|
if (data.downloads.windows) document.getElementById('dl-win').href = data.downloads.windows;
|
||||||
|
if (data.downloads.darwin) document.getElementById('dl-mac').href = data.downloads.darwin;
|
||||||
|
if (data.downloads.linux) document.getElementById('dl-lin').href = data.downloads.linux;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skin upload
|
||||||
|
document.getElementById('skinForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = document.getElementById('skinFile').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('skin', file);
|
||||||
|
const alert = document.getElementById('alertSkin');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/profile/skin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token},
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = data.error || 'Ошибка загрузки';
|
||||||
|
} else {
|
||||||
|
alert.className = 'alert alert-success show';
|
||||||
|
alert.textContent = 'Скин загружен!';
|
||||||
|
loadProfile();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cape upload
|
||||||
|
document.getElementById('capeForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = document.getElementById('capeFile').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('cape', file);
|
||||||
|
const alert = document.getElementById('alertCape');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/profile/cape', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token},
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = data.error || 'Ошибка загрузки';
|
||||||
|
} else {
|
||||||
|
alert.className = 'alert alert-success show';
|
||||||
|
alert.textContent = 'Плащ загружен!';
|
||||||
|
loadProfile();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete skin
|
||||||
|
document.getElementById('deleteSkin').addEventListener('click', async function() {
|
||||||
|
const alert = document.getElementById('alertSkin');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/profile/skin', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка удаления';
|
||||||
|
} else {
|
||||||
|
alert.className = 'alert alert-success show';
|
||||||
|
alert.textContent = 'Скин удалён';
|
||||||
|
loadProfile();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete cape
|
||||||
|
document.getElementById('deleteCape').addEventListener('click', async function() {
|
||||||
|
const alert = document.getElementById('alertCape');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/profile/cape', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'Authorization': 'Bearer ' + token}
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка удаления';
|
||||||
|
} else {
|
||||||
|
alert.className = 'alert alert-success show';
|
||||||
|
alert.textContent = 'Плащ удалён';
|
||||||
|
loadProfile();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert.className = 'alert alert-error show';
|
||||||
|
alert.textContent = 'Ошибка сети';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
77
internal/templates/html/register.html
Normal file
77
internal/templates/html/register.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="container-narrow">
|
||||||
|
<div class="card text-center">
|
||||||
|
<h1 style="margin-bottom:0.5rem">🎮 Создать аккаунт</h1>
|
||||||
|
<p class="muted mb-0">Присоединяйся к MrixsCraft и начни играть.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="alert" class="alert alert-error"></div>
|
||||||
|
<form id="regForm" novalidate>
|
||||||
|
<label for="username">Никнейм</label>
|
||||||
|
<input type="text" id="username" name="username" required minlength="3" maxlength="20" autocomplete="username"
|
||||||
|
placeholder="Player" pattern="[a-zA-Z0-9_]{3,20}">
|
||||||
|
<p class="form-error" id="err-username">3–20 символов: латиница, цифры, _</p>
|
||||||
|
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email" placeholder="player@email.com">
|
||||||
|
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="6" autocomplete="new-password" placeholder="Минимум 6 символов">
|
||||||
|
|
||||||
|
<label for="password2">Подтверждение пароля</label>
|
||||||
|
<input type="password" id="password2" name="password2" required autocomplete="new-password" placeholder="Повтори пароль">
|
||||||
|
<p class="form-error" id="err-password">Пароли не совпадают</p>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width:100%">Создать аккаунт</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-2 muted">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('regForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const alert = document.getElementById('alert');
|
||||||
|
alert.className = 'alert alert-error';
|
||||||
|
alert.classList.remove('show');
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const password2 = document.getElementById('password2').value;
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||||
|
document.getElementById('err-username').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('err-username').style.display = 'none';
|
||||||
|
|
||||||
|
if (password !== password2) {
|
||||||
|
document.getElementById('err-password').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('err-password').style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/web/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username, email, password})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.textContent = data.error || 'Ошибка регистрации';
|
||||||
|
alert.classList.add('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Success — redirect to login
|
||||||
|
window.location.href = '/login';
|
||||||
|
} catch (err) {
|
||||||
|
alert.textContent = 'Ошибка сети. Попробуй ещё раз.';
|
||||||
|
alert.classList.add('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -1,2 +1,171 @@
|
|||||||
// package templates handles Go html/template rendering for the site and admin panel.
|
// package templates handles Go html/template rendering for the website.
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed html/*.html
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
// pageData is passed to all templates.
|
||||||
|
type pageData struct {
|
||||||
|
Title string
|
||||||
|
Username string
|
||||||
|
UUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves template-rendered pages.
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
templates map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCookieToken extracts the authentication token from the request cookie.
|
||||||
|
func (h *Handler) getCookieToken(r *http.Request) string {
|
||||||
|
// Check cookie named "token"
|
||||||
|
if cookie, err := r.Cookie("token"); err == nil {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new templates handler and parses embedded templates.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
h := &Handler{db: db, cfg: cfg, templates: make(map[string]*template.Template)}
|
||||||
|
h.parseTemplates()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts template-rendered pages.
|
||||||
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /", h.index)
|
||||||
|
mux.HandleFunc("GET /login", h.loginPage)
|
||||||
|
mux.HandleFunc("GET /register", h.registerPage)
|
||||||
|
mux.HandleFunc("GET /profile", h.profilePage)
|
||||||
|
mux.HandleFunc("GET /admin", h.adminPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page handlers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(w, "index.html", pageData{Title: "Главная"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.render(w, "login.html", pageData{Title: "Вход"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.render(w, "register.html", pageData{Title: "Регистрация"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) profilePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get token from cookie only (HTML endpoint)
|
||||||
|
token := h.getCookieToken(r)
|
||||||
|
if token == "" {
|
||||||
|
// No token provided, redirect to login
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token (just check if valid, don't need role check for profile)
|
||||||
|
var userID int
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT user_id
|
||||||
|
FROM yggdrasil_sessions
|
||||||
|
WHERE access_token = $1 AND expires_at > NOW()`,
|
||||||
|
token,
|
||||||
|
).Scan(&userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Invalid or expired token, redirect to login
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is authenticated, render profile page
|
||||||
|
h.render(w, "profile.html", pageData{Title: "Профиль"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) adminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get token from cookie only (HTML endpoint)
|
||||||
|
token := h.getCookieToken(r)
|
||||||
|
if token == "" {
|
||||||
|
// No token provided, redirect to login
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token and check admin role
|
||||||
|
var userID int
|
||||||
|
var role string
|
||||||
|
err := h.db.Pool().QueryRow(r.Context(),
|
||||||
|
`SELECT u.id, u.role
|
||||||
|
FROM yggdrasil_sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.access_token = $1 AND s.expires_at > NOW()`,
|
||||||
|
token,
|
||||||
|
).Scan(&userID, &role)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Invalid or expired token, redirect to login
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if role != "admin" {
|
||||||
|
// Not admin, show forbidden
|
||||||
|
http.Error(w, "Forbidden: admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is authenticated and has admin role, render admin page
|
||||||
|
h.render(w, "admin.html", pageData{Title: "Админ-панель"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// render executes the "base.html" template which {{template "content" .}}
|
||||||
|
// pulls in the per-page content block.
|
||||||
|
func (h *Handler) render(w http.ResponseWriter, page string, data pageData) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
tmpl, ok := h.templates[page]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("Template not found: %s", page)
|
||||||
|
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||||
|
log.Printf("Template error (%s): %v", page, err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template parsing ───────────────────────────────────────────
|
||||||
|
|
||||||
|
// parseTemplates parses each page template individually by combining the base
|
||||||
|
// layout with the specific page content. This avoids the issue where
|
||||||
|
// multiple {{define "content"}} blocks in wildcard-parsed files overwrite
|
||||||
|
// each other (last alphabetically wins).
|
||||||
|
func (h *Handler) parseTemplates() {
|
||||||
|
pages := []string{"index.html", "login.html", "register.html", "profile.html", "admin.html"}
|
||||||
|
for _, page := range pages {
|
||||||
|
tmpl, err := template.New("base.html").Funcs(template.FuncMap{}).ParseFS(templateFS, "html/base.html", "html/"+page)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template parse error (%s): %v", page, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.templates[page] = tmpl
|
||||||
|
log.Printf("Loaded template: %s", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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);
|
||||||
12
migrations/002_migration_history.sql
Normal file
12
migrations/002_migration_history.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- 002_migration_history.sql — Track applied migrations.
|
||||||
|
-- Run manually: psql $DATABASE_URL -f migrations/002_migration_history.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS migration_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Record this migration.
|
||||||
|
INSERT INTO migration_history (filename) VALUES ('002_migration_history.sql')
|
||||||
|
ON CONFLICT (filename) DO NOTHING;
|
||||||
14
migrations/README.md
Normal file
14
migrations/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Migrations
|
||||||
|
|
||||||
|
Applied manually in order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f migrations/001_init.sql # schema
|
||||||
|
psql "$DATABASE_URL" -f migrations/002_migration_history.sql # tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
Check applied:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM migration_history ORDER BY id;
|
||||||
|
```
|
||||||
@@ -1,2 +1,109 @@
|
|||||||
// package utils provides shared utility functions (SHA-1, ZIP, etc.).
|
// package utils provides shared utility functions (hashing, HTTP helpers, ZIP).
|
||||||
|
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Hashing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SHA1Bytes returns the SHA-1 hex string of the given data.
|
||||||
|
func SHA1Bytes(data []byte) string {
|
||||||
|
h := sha1.Sum(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256Bytes returns the SHA-256 hex string of the given data.
|
||||||
|
func SHA256Bytes(data []byte) string {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA1File computes the SHA-1 hash of a file at the given path.
|
||||||
|
func SHA1File(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha1.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// WriteJSON writes a JSON response with the given status code.
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError writes a JSON error response.
|
||||||
|
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
WriteJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ZIP ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Unzip extracts a ZIP archive to the destination directory.
|
||||||
|
// Returns the list of extracted file paths.
|
||||||
|
// Protects against zip-slip by validating that each entry's target path
|
||||||
|
// stays within the destination directory.
|
||||||
|
func Unzip(data []byte, dest string) ([]string, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extracted []string
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(dest, f.Name)
|
||||||
|
|
||||||
|
// Zip-slip protection.
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(out, rc)
|
||||||
|
out.Close()
|
||||||
|
rc.Close()
|
||||||
|
|
||||||
|
extracted = append(extracted, f.Name)
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user