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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"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() {
|
||||
port := os.Getenv("SERVER_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
ctx := context.Background()
|
||||
|
||||
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.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)
|
||||
fmt.Fprintln(w, "ok")
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
log.Printf("MrixsCraft Server starting on :%s", port)
|
||||
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
// Yggdrasil API.
|
||||
authHandler := auth.NewHandler(db, cfg)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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