Compare commits

...

43 Commits

Author SHA1 Message Date
7a8e79123f feat: unified modpack creation and upload form in admin panel
All checks were successful
CI / lint (push) Successful in 18s
CI / test (push) Successful in 44s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m23s
- Объединены формы создания модпака и загрузки файлов в одну
- Slug генерируется автоматически из названия с возможностью редактирования
- Поддержка загрузки файлов сразу при создании/обновлении модпака
- Автоматическое распаковывание ZIP архивов
- Сохранение файлов в CAS с дедупликацией по SHA-1
- Улучшен UI с прогресс баром и валидацией форм
2026-06-09 17:55:58 +03:00
a17b80230a fix: remove unused auth import in templates.go
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 44s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m13s
2026-06-07 23:16:57 +03:00
75ea7c70c2 auth: implement cookie-based auth for HTML endpoints and Bearer token auth for API endpoints
Some checks failed
CI / lint (push) Failing after 15s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / docker (push) Has been skipped
Details:

  • HTML endpoints (/, /profile, /admin, /login, /register):

    - Authenticate via HTTP-only cookie named 'token'

    - Handlers in internal/templates/templates.go check cookie validity

    - /admin endpoint additionally checks for role='admin'

    - Unauthenticated users redirected to /login

    - Non-admin users accessing /admin get HTTP 403 Forbidden

  • API endpoints (/api/*):

    - Authenticate via Bearer token in Authorization header only

    - Handlers in internal/api/api.go use authenticateRequest() function

    - Function extracts token from 'Authorization: Bearer <token>' header

    - Validates token against yggdrasil_sessions table

    - No cookie checking for API endpoints (launcher compatibility)

  • Web login (/api/web/login):

    - Sets HTTP-only cookie 'token' for browser storage

    - Returns JSON with token, UUID, username for JS localStorage

    - Maintains backward compatibility with existing JavaScript

  • JavaScript in HTML pages:

    - Gets token from localStorage (set by login response)

    - Sets Authorization: Bearer <token> header for API fetch calls

    - Updated admin.html and profile.js to include token in headers

This separation ensures:

  • HTML endpoints work automatically with browser cookies

  • API endpoints work with browsers (via JS) and launchers (Bearer tokens)

  • Security sensitive actions require proper role validation

  • Clean separation of concerns between document and API interfaces
2026-06-07 23:11:51 +03:00
5bd8a549ca fix: require authentication and admin role for /admin endpoint
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 42s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m10s
2026-06-07 21:16:23 +03:00
b9e986d25a feat: добавить веб-интерфейс админ-панели для управления модпаками
All checks were successful
CI / lint (push) Successful in 1m1s
CI / test (push) Successful in 42s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m16s
2026-06-07 19:06:27 +03:00
f765fecf24 fix: check skin_hash/cape_hash instead of skin/cape in profile JS
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 41s
CI / build (push) Successful in 17s
CI / docker (push) Successful in 1m13s
2026-06-06 20:36:19 +03:00
f4f7a52749 fix: remove inline onerror from skin/cape images in template
All checks were successful
CI / lint (push) Successful in 16s
CI / docker (push) Successful in 1m10s
CI / test (push) Successful in 40s
CI / build (push) Successful in 17s
Inline onerror was firing on empty src, hiding images before JS could load them
2026-06-06 20:27:18 +03:00
b8c136878b fix: pass os/arch to launcher latest endpoint and improve skin/cape loading
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 41s
CI / build (push) Successful in 17s
CI / docker (push) Successful in 1m7s
2026-06-06 20:08:37 +03:00
329c0d3fda fix: skin/cape image loading with error handling
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 43s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m10s
2026-06-06 20:00:26 +03:00
6a8213a234 fix: use skin_hash and cape_hash for skin/cape URLs in profile page
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 43s
CI / build (push) Successful in 17s
CI / docker (push) Successful in 1m17s
2026-06-06 19:44:58 +03:00
7ae0f44fd6 fix: skin and cape URLs in profile page to use /skins/ endpoint
All checks were successful
CI / lint (push) Successful in 1m0s
CI / test (push) Successful in 42s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m23s
2026-06-06 19:29:14 +03:00
3a148fabe2 fix: add gosu to chown skins directory and drop privileges
All checks were successful
CI / lint (push) Successful in 19s
CI / test (push) Successful in 19s
CI / build (push) Successful in 19s
CI / docker (push) Successful in 1m6s
- Install gosu in container to change ownership of mounted volume\n- Entrypoint now chowns /var/www/cdn/skins to appuser:appuser and runs server as non-root\n- This should resolve permission denied errors when creating skin subdirectories\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:47:33 +03:00
a143399643 fix: ensure upload directories can be created at runtime
All checks were successful
CI / lint (push) Successful in 19s
CI / test (push) Successful in 20s
CI / build (push) Successful in 19s
CI / docker (push) Successful in 1m9s
- Removed fatal directory creation from config.Load to allow server start even if volumes not prepped\n- Kept runtime directory creation in uploadSkin/uploadCape to create skin subdirectories on first upload\n- Confirmed docker-compose.yml includes cdn_skins volume for persistence\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:31:26 +03:00
1c69721b47 fix: ensure skins directory existence and update infrastructure
All checks were successful
CI / lint (push) Successful in 19s
CI / test (push) Successful in 19s
CI / build (push) Successful in 19s
CI / docker (push) Successful in 1m9s
- Create SKINS_DIR on startup to prevent upload errors\n- Add cdn_skins volume to docker-compose for persistence and Caddy serving\n- Update watchtower image to nickfedor/watchtower for Docker API compatibility\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:08:07 +03:00
d419d59fe3 fix: create skins directory on startup
All checks were successful
CI / lint (push) Successful in 59s
CI / test (push) Successful in 29s
CI / build (push) Successful in 19s
CI / docker (push) Successful in 1m16s
2026-06-05 16:37:13 +03:00
74ad023a36 fix: align form field names in skin/cape upload (file → skin/cape)
All checks were successful
CI / lint (push) Successful in 18s
CI / test (push) Successful in 20s
CI / build (push) Successful in 20s
CI / docker (push) Successful in 1m7s
JS sent fd.append('file', ...) but Go expected r.FormFile("skin") / r.FormFile("cape").
2026-06-04 18:33:05 +03:00
c22c860ec5 fix: set base.html as root template name and use ExecuteTemplate
All checks were successful
CI / lint (push) Successful in 19s
CI / test (push) Successful in 20s
CI / build (push) Successful in 20s
CI / docker (push) Successful in 1m11s
template.New("base.html") makes base.html the root template instead of ""
2026-06-04 17:30:46 +03:00
3f2fe0043a chore: gofmt
All checks were successful
CI / docker (push) Successful in 1m12s
CI / lint (push) Successful in 19s
CI / test (push) Successful in 20s
CI / build (push) Successful in 20s
2026-06-04 17:06:31 +03:00
21d48200f5 fix: parse templates individually to prevent content block overwrite
Some checks failed
CI / lint (push) Failing after 19s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / docker (push) Has been skipped
Root cause: ParseFS with wildcard html/*.html caused all {{define "content"}}

blocks to overwrite each other — last alphabetically (register.html) won for

all pages. Now each page is parsed separately with base.html as its own template.

Also fix footer link: GitHub → Gitea project page.
2026-06-04 16:57:34 +03:00
2f1f1ef7d6 fix: embed HTML templates into binary via go:embed
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 18s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m1s
Switched from runtime file-based template loading (os.Stat + ParseGlob)
to compile-time embedding (embed.FS + ParseFS). Templates are now
bundled into the binary — no need to COPY them into Docker image.

This fixes the fallback response issue where h.loaded was always false
because templates weren't present in the container filesystem.
2026-06-04 06:51:11 +03:00
394003d8c0 fix: render base.html layout instead of bare page templates
All checks were successful
CI / lint (push) Successful in 48s
CI / test (push) Successful in 18s
CI / build (push) Successful in 17s
CI / docker (push) Successful in 1m10s
ExecuteTemplate now renders base.html which calls {{template "content" .}},
so the full layout (header, footer, styles) wraps each page.
2026-06-04 06:29:46 +03:00
008d9a129e feat: redesign website templates with full pages
All checks were successful
CI / lint (push) Successful in 18s
CI / test (push) Successful in 20s
CI / build (push) Successful in 19s
CI / docker (push) Successful in 1m7s
- Redesigned base.html: dark Minecraft theme, sticky header, responsive grid, cards, server cards with status indicators, profile styles
- index.html: hero section, server list grid, how-to-start steps, features section
- login.html: centered card layout, client-side validation, fetch API
- registration.html: password confirmation, pattern validation, error alerts
- profile.html: new page — skin/cape upload & delete, launcher download links, auth-gated via localStorage token
- templates.go: added /profile route, extended pageData with Username/UUID
2026-06-03 20:37:24 +03:00
e94cd4c23c fix: remove duplicate route for sessionProfile
All checks were successful
CI / lint (push) Successful in 1m3s
CI / test (push) Successful in 26s
CI / build (push) Successful in 20s
CI / docker (push) Successful in 1m22s
Remove conflicting 'GET /sessionserver/session/minecraft/profile/{unsigned}'
route that panicked due to identical pattern matching as '{uuid}'.
The 'unsigned' variant is now handled via ?unsigned=true query parameter.
2026-06-03 18:43:41 +03:00
3e4b97e262 fix: update builder image to golang:1.25-alpine
All checks were successful
CI / lint (push) Successful in 19s
CI / test (push) Successful in 19s
CI / build (push) Successful in 18s
CI / docker (push) Successful in 1m12s
go.mod requires Go 1.25+, but Dockerfile was still using golang:1.22-alpine
as the builder image. This caused CI build failure:
  "go.mod requires go >= 1.25.0 (running go 1.22.12)"
2026-06-01 17:20:51 +03:00
51e45d9327 fix: use PAT for Gitea Container Registry authentication
Some checks failed
CI / lint (push) Successful in 19s
CI / test (push) Successful in 18s
CI / build (push) Successful in 18s
CI / docker (push) Failing after 30s
Gitea's GITHUB_TOKEN is read-only for packages (issue #23642).
Switch to PACKAGES_TOKEN Personal Access Token with write:package scope.

Setup required:
1. Gitea → User Settings → Applications → Generate Token (read:package, write:package)
2. Repo → Settings → Actions → Secrets → PACKAGES_TOKEN
2026-06-01 17:16:47 +03:00
79ebed5b01 fix: repair CI/CD pipeline for Gitea Actions
Some checks failed
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 1m17s
CI / build (push) Successful in 21s
CI / docker (push) Failing after 2m27s
- Remove actions/upload-artifact@v4 (not supported on Gitea, GHESNotSupportedError)
- Add job permissions (packages: write, contents: read)
- Fix master branch condition (github.ref_name compatible with Gitea)
- Fix Docker login: use gitea.actor + GITHUB_TOKEN instead of reserved GITEA_ secrets
- Lowercase image tags per Docker spec (mrixs/mrixscraft-server)
- Sync docker-compose.yml image reference
2026-06-01 17:01:44 +03:00
3499963205 fix: add per-hash mutex to prevent concurrent CAS writes
Some checks failed
CI / lint (push) Successful in 11m11s
CI / test (push) Successful in 10m55s
CI / build (push) Failing after 5m36s
CI / docker (push) Has been skipped
StoreFile now uses a per-hash sync.Mutex to prevent race conditions
when multiple workers (launcher fetcher or parallel uploads) write
the same file simultaneously. Duplicate writes are idempotent —
if another goroutine stored the file while we waited, return the
existing hash without re-writing.
2026-06-01 06:51:54 +03:00
7b3b97c5f8 ci: add Go modules/build cache, fix master branch triggers
Some checks failed
CI / lint (push) Successful in 11m11s
CI / test (push) Successful in 11m1s
CI / build (push) Failing after 5m40s
CI / docker (push) Has been skipped
- Add actions/cache@v4 for GOMODCACHE and GOCACHE on lint/test/build
- Switch from setup-go built-in cache to explicit cache control
- Fix branch refs: main → master for PR, artifact, and docker jobs
- Use registry-based Docker layer caching (mode=max)
- Extract GO_VERSION env var for single-point version management
2026-05-30 20:49:23 +03:00
4efcc770ac fix: format all Go files with gofmt
All checks were successful
CI / lint (push) Successful in 9m54s
CI / test (push) Successful in 10m19s
CI / build (push) Successful in 9m58s
CI / docker (push) Has been skipped
- Fix alignment in struct fields (sessionProfileResponse, textureInfo, Handler)
- Align struct field values in internal/templates/templates.go, internal/api/api.go
2026-05-30 20:00:54 +03:00
8d1f956a8b fix: regenerate go.sum with go mod tidy
Some checks failed
CI / lint (push) Failing after 5m22s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / docker (push) Has been skipped
- go.sum was missing checksums for pgx/v5 and golang.org/x/crypto
- go.sum was accidentally gitignored; forcing add for CI reproducibility
- Consider un-ignoring go.sum if this repo is a library (per Go conventions)
2026-05-30 19:53:37 +03:00
69eb6ddd5f fix: add go mod download before go vet and tests
Some checks failed
CI / lint (push) Failing after 11s
CI / build (push) Has been skipped
CI / test (push) Has been skipped
CI / docker (push) Has been skipped
- Explicitly run go mod download so modules are available for go vet
- Remove cache: true (unnecessary now that modules are explicitly fetched)
2026-05-30 19:48:30 +03:00
08aaa7a3c8 fix: enable Go module cache in CI workflow
Some checks failed
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / docker (push) Has been skipped
- Add cache: true to actions/setup-go@v5 so modules persist between steps
- Fixes "missing go.sum entry" error on go vet step
2026-05-30 19:45:52 +03:00
7ad02cb1b2 feat: implement email validation, CI/CD pipeline, migration history, and web templates
Some checks failed
CI / lint (push) Failing after 21s
CI / build (push) Has been skipped
CI / test (push) Has been skipped
CI / docker (push) Has been skipped
Email validation:
- Replace @/. check with net/mail.ParseAddress on register
- Add size limit check (max 254 chars, RFC 5321)

CI/CD Pipeline:
- Add .gitea/workflows/ci.yml (lint → test → build → docker push)
- Registry: gitea.mrixs.me/Mrixs/MrixsCraft-server
- Push only on main branch

Database:
- Add migrations/002_migration_history.sql (tracking applied migrations)
- Add migrations/README.md (manual apply instructions)

Web Templates:
- Add base.html with Minecraft-themed layout (dark + green accent)
- Add index.html, login.html, register.html with POST forms
- Rewrite templates.go for data-driven rendering with pageData struct
- Fallback placeholder preserved when templates dir missing
2026-05-30 00:39:51 +03:00
e1cc999ea8 refactor: deduplicate sha1Hex/writeJSON/writeError into pkg/utils
- admin.go: replace local sha1Hex, sha256Hex, writeJSON, writeError with pkg/utils equivalents
- auth.go: replace local writeJSON with utils.WriteJSON; rewrite writeError as thin wrapper
- cas.go: remove local sha1Hex and unused writeJSON; use utils.SHA1Bytes
- pkg/utils.go: add WriteJSON, WriteError; reorder imports
2026-05-29 23:53:33 +03:00
d418ae2b54 fix: add panic recovery, rate limiting, timing-safe CI token
- Add Recovery middleware (catches panics, returns 500, logs stack trace)
- Add RateLimiter to middleware chain (30 req/min, burst 60 per IP)
- Fix CI token comparison with subtle.ConstantTimeCompare (timing attack)
- Middleware chain: Recovery → Logging → RateLimit → CORS → mux

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 21:08:01 +03:00
5fba2e78d5 feat: add Docker infrastructure, migrations, CI/CD client, session cleanup, tests
Docker & Deployment:
- Add Dockerfile (multi-stage, alpine, non-root)
- Add docker-compose.yml (caddy, backend, postgres, watchtower)
- Add Caddyfile (TLS, file_server, reverse proxy)
- Add .env.example

Database:
- Add migrations/001_init.sql (all tables + indexes)

CI/CD:
- Add cmd/ci-release/main.go (launcher binary upload tool)

Session management:
- Add internal/session/cleanup.go (background expired session cleanup)
- Integrate cleanup worker into main.go

Bug fixes:
- Fix launcherLatest download URL to include version segment
- Fix serveLauncherAsset path to match route pattern
- Add Content-Type detection from file extension in CAS serveFile
- Add empty-field validation in webLogin
- Format string fix in ci-release (%d → %s for resp.Status)

Tests:
- Add internal/auth/auth_test.go (8 tests)
- Add internal/cas/cas_test.go (7 tests)
- Add internal/session/cleanup_test.go (1 test)
- Add internal/api/api_test.go (5 tests)
- All tests passing, go vet clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-29 20:09:00 +03:00
81c42e1a9a feat: migrate passwords from SHA-256 to bcrypt
- Replace SHA-256 hex hashing with bcrypt (cost 10) for password storage
- VerifyPassword now uses bcrypt.CompareHashAndPassword
- HashPassword returns (string, error) instead of string
- Add IsBcryptHash helper to detect legacy hashes for future migration
- Remove duplicate verifyPassword from api.go (already done in prev commit)
- Promote golang.org/x/crypto to direct dependency
2026-05-27 16:31:38 +03:00
01cce981c5 feat: implement skins/capes, profile endpoints, session server
Skins & capes:
- Fix uploadSkin auth: Bearer token instead of user_id form hack
- Add POST /api/web/profile/cape (upload cape)
- Add DELETE /api/web/profile/skin and DELETE /api/web/profile/cape
- Validate skin PNG dimensions (64x32, 64x64, 128x128, 128x64)
- Add size limits: 1 MB for skins, 2 MB for capes
- Add basic email validation on register

Profile & session server:
- Add GET /api/web/profile/{uuid} — public profile with skin/cape hashes
- Add GET /sessionserver/session/minecraft/profile/{uuid} — Mojang-compatible
  texture response for game client
- Add POST /authserver/invalidate and POST /authserver/signout
- Export VerifyPassword and ExtractBearer from auth package
- Remove duplicate verifyPassword from api.go
- Add PlayerTextures model to database.go
2026-05-27 11:45:33 +03:00
e4fea937aa feat: implement CAS module, middleware, utils, and templates
- CAS: GET /files/{hash} with immutable cache headers, launcher asset
  serving, hash validation, StoreFile/VerifyAndStore helpers
- Middleware: CORS, request logging, per-IP token bucket rate limiter
- Utils: SHA1Bytes, SHA256Bytes, SHA1File, Unzip with zip-slip protection
- Templates: placeholder handler with html/template discovery
- Wire CAS routes and middleware chain (Logging → CORS) in main.go
2026-05-26 15:11:41 +03:00
2f07fbf379 feat: add admin handler (modpack CRUD, file upload, manifest, launcher release)
- modpack CRUD: GET/POST/PUT/DELETE /api/admin/modpacks
- file upload: POST /api/admin/modpacks/{slug}/upload — multipart, ZIP extraction, CAS storage
- manifest: POST /api/admin/modpacks/{slug}/manifest — scan instance dir, generate manifest.json
- launcher release: POST /api/admin/launcher/release — CI/CD endpoint, SHA-256 verify, DB registration
- auth middleware: Bearer token + admin role check + X-CI-Token for CI/CD
- zip-slip protection in file extraction

Co-Authored-By: OWL <noreply@anthropic.com>
2026-05-26 14:03:17 +03:00
475ff9bfa2 feat: add API handler (register, login, skin, launcher, servers, manifest)
- register: POST /api/web/register — create user with SHA-256 password hash
- login: POST /api/web/login — credentials check + session token
- uploadSkin: POST /api/web/profile/skin — PNG upload, SHA-1 CAS storage
- launcherLatest: GET /api/launcher/latest — latest launcher version + download URL
- serversList: GET /api/servers.json — active modpacks list
- instanceManifest: GET /api/instances/{slug}/manifest.json — modpack manifest
- serveSkin: GET /skins/{hash}.png — skin file serving with cache headers
- PathValue-based routing (Go 1.22+)

Co-Authored-By: OWL <noreply@anthropic.com>
2026-05-26 13:31:22 +03:00
d205320e0e refactor: export GenerateToken for use by api package 2026-05-26 13:30:39 +03:00
aa7d3a8509 feat: add server foundation (config, database, auth, main)
- config: Load from env vars (SERVER_PORT, DATABASE_URL, JWT_SECRET, CAS_DIR, etc.)
- database: pgx/v5 connection pool, models (User, YggdrasilSession, Modpack, GlobalFile, LauncherRelease)
- auth: Yggdrasil endpoints (authenticate, refresh, validate) with SHA-256 password hashing, token rotation
- main: graceful shutdown, HTTP server on configured port
- go.mod: module gitea.mrixs.me/Mrixs/MrixsCraft-server, pgx/v5 dependency

Co-Authored-By: OWL <noreply@anthropic.com>
2026-05-26 13:03:21 +03:00
32 changed files with 4981 additions and 19 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# MrixsCraft — environment variables for Docker Compose.
# Copy to .env and fill in real values. Never commit .env.
# PostgreSQL password (generate: openssl rand -base64 32)
DB_PASSWORD=change-me
# CI/CD token for /api/admin/launcher/release endpoint.
# Must match CI_TOKEN in Gitea Actions secrets.
CI_TOKEN=change-me-to-a-random-secret
# JWT secret for web session signing (generate: openssl rand -base64 48)
JWT_SECRET=change-me-to-another-random-secret

134
.gitea/workflows/ci.yml Normal file
View 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
View File

@@ -0,0 +1,57 @@
# Caddyfile — MrixsCraft reverse proxy + static file serving.
# TLS is automatic. Adjust domain names to your actual domains.
# ── CDN: CAS files (immutable, long cache) ────────────────────
cdn.mrixs.me {
root * /var/www/cdn/files
# CAS files — content-addressed, never change.
handle /files/* {
file_server {
hide .htaccess
}
header Cache-Control "public, max-age=31536000, immutable"
}
# Skins — shorter cache so players see changes.
handle /skins/* {
file_server {
hide .htaccess
}
header Cache-Control "public, max-age=3600"
}
}
# ── API + Yggdrasil ───────────────────────────────────────────
minecraft.mrixs.me {
# Yggdrasil authentication (launcher + game client).
handle /authserver/* {
reverse_proxy backend:8080
}
handle /sessionserver/* {
reverse_proxy backend:8080
}
# Public API (launcher + website).
handle /api/* {
reverse_proxy backend:8080
}
# Skin serving.
handle /skins/* {
reverse_proxy backend:8080
}
# CAS file serving (fallback if not served by CDN domain).
handle /files/* {
reverse_proxy backend:8080
}
# Everything else — frontend / templates.
handle {
reverse_proxy backend:8080
}
}

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Multi-stage build for MrixsCraft backend.
# Final image: ~20 MB, non-root, no toolchain.
FROM golang:1.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
View File

@@ -0,0 +1,129 @@
// Command ci-release uploads a new launcher binary to the server.
//
// Usage:
//
// ci-release \
// --url https://minecraft.mrixs.me \
// --token <CI_TOKEN> \
// --version 1.2.0 \
// --os windows \
// --arch amd64 \
// --file ./build/launcher-windows-amd64.exe
//
// SHA-256 is computed automatically from the file.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
func main() {
var (
serverURL = flag.String("url", "", "Server base URL (e.g. https://minecraft.mrixs.me)")
token = flag.String("token", "", "CI token (from CI_SECRET)")
version = flag.String("version", "", "Launcher version (e.g. 1.2.0)")
osParam = flag.String("os", "", "Target OS: windows, linux, darwin")
arch = flag.String("arch", "", "Target arch: amd64, arm64")
filePath = flag.String("file", "", "Path to launcher binary")
)
flag.Parse()
// Validate required flags.
missing := false
for _, f := range []struct{ name, value string }{
{"--url", *serverURL},
{"--token", *token},
{"--version", *version},
{"--os", *osParam},
{"--arch", *arch},
{"--file", *filePath},
} {
if f.value == "" {
fmt.Fprintf(os.Stderr, "ERROR: missing required flag %s\n", f.name)
missing = true
}
}
if missing {
flag.Usage()
os.Exit(1)
}
// Read the binary.
data, err := os.ReadFile(*filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR reading file: %v\n", err)
os.Exit(1)
}
// Compute SHA-256.
hash := sha256.Sum256(data)
sha256hex := hex.EncodeToString(hash[:])
fmt.Printf("Uploading %s (%s/%s) v%s (%d bytes, sha256=%s)\n",
filepath.Base(*filePath), *osParam, *arch, *version, len(data), sha256hex)
// Build multipart form.
var body bytes.Buffer
w := multipart.NewWriter(&body)
// Text fields.
for _, f := range []struct{ name, value string }{
{"version", *version},
{"os", *osParam},
{"arch", *arch},
{"sha256", sha256hex},
} {
if err := w.WriteField(f.name, f.value); err != nil {
fmt.Fprintf(os.Stderr, "ERROR building form: %v\n", err)
os.Exit(1)
}
}
// File part.
fw, err := w.CreateFormFile("file", filepath.Base(*filePath))
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR creating form file: %v\n", err)
os.Exit(1)
}
if _, err := fw.Write(data); err != nil {
fmt.Fprintf(os.Stderr, "ERROR writing file data: %v\n", err)
os.Exit(1)
}
w.Close()
// Send request.
url := *serverURL + "/api/admin/launcher/release"
req, err := http.NewRequest("POST", url, &body)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR creating request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("X-CI-Token", *token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR sending request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
fmt.Printf("HTTP %s: %s\n", resp.Status, string(respBody))
if resp.StatusCode != http.StatusCreated {
os.Exit(1)
}
fmt.Println("✓ Release uploaded successfully.")
}

View File

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

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

View File

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

View File

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

@@ -0,0 +1,216 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
)
// newTestHandler creates an API handler with a nil DB for testing validation
// and routing only. Handers that touch the database will panic with nil DB —
// integration tests with a real database cover those paths.
func newTestHandler(t *testing.T) *Handler {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{
Port: 8080,
CASDir: dir,
SkinsDir: dir,
BaseURL: "https://test.example.com",
JWTSecret: "test-secret",
}
return &Handler{db: &database.DB{}, cfg: cfg}
}
// TestRegisterValidation tests input validation in the register handler
// without requiring a database connection.
func TestRegisterValidation(t *testing.T) {
h := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
tests := []struct {
name string
body string
wantStatus int
wantErr string
}{
{
name: "empty body",
body: "{}",
wantStatus: http.StatusBadRequest,
wantErr: "Username, email and password are required",
},
{
name: "invalid email",
body: `{"username":"test","email":"notanemail","password":"pass"}`,
wantStatus: http.StatusBadRequest,
wantErr: "Invalid email address",
},
{
name: "invalid json",
body: "not json",
wantStatus: http.StatusBadRequest,
wantErr: "Invalid JSON",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/web/register",
bytes.NewReader([]byte(tt.body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if got := resp["error"]; got != tt.wantErr {
t.Errorf("error = %q, want %q", got, tt.wantErr)
}
})
}
}
// TestWebLoginValidation tests input validation in the web login handler.
func TestWebLoginValidation(t *testing.T) {
h := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
tests := []struct {
name string
body string
wantStatus int
}{
{
name: "empty credentials",
body: `{"username":"","password":""}`,
wantStatus: http.StatusBadRequest,
},
{
name: "missing username",
body: `{"password":"secret"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "missing password",
body: `{"username":"test"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "invalid json",
body: "not json",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/web/login",
bytes.NewReader([]byte(tt.body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
})
}
}
// TestLauncherLatest_MissingParams tests that missing query parameters
// return 400 without hitting the database.
func TestLauncherLatest_MissingParams(t *testing.T) {
h := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
queries := []string{
"/api/launcher/latest",
"/api/launcher/latest?os=windows",
"/api/launcher/latest?arch=amd64",
}
for _, q := range queries {
req := httptest.NewRequest("GET", q, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d", q, w.Code)
}
}
}
// TestAuthMiddleware_NoToken tests that protected endpoints reject
// requests without a Bearer token.
func TestAuthMiddleware_NoToken(t *testing.T) {
h := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
protected := []struct {
method string
path string
}{
{"POST", "/api/web/profile/skin"},
{"POST", "/api/web/profile/cape"},
{"DELETE", "/api/web/profile/skin"},
{"DELETE", "/api/web/profile/cape"},
}
for _, ep := range protected {
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
req := httptest.NewRequest(ep.method, ep.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("%s %s: expected 401, got %d", ep.method, ep.path, w.Code)
}
var resp map[string]string
json.Unmarshal(w.Body.Bytes(), &resp)
if !strings.Contains(resp["error"], "Missing authorization") {
t.Errorf("unexpected error: %s", resp["error"])
}
})
}
}
// TestRoutesRegistered verifies all expected API routes are mounted
// and return proper HTTP status codes (not 404 for known paths).
func TestRoutesRegistered(t *testing.T) {
h := newTestHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Public routes that should respond without a database.
// Only routes with early validation (before DB access) are listed.
knownRoutes := []struct {
method string
path string
}{
{"POST", "/api/web/register"},
{"POST", "/api/web/login"},
{"GET", "/api/launcher/latest"},
}
for _, r := range knownRoutes {
t.Run(r.method+" "+r.path, func(t *testing.T) {
req := httptest.NewRequest(r.method, r.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Should not be 404 (route exists).
if w.Code == http.StatusNotFound {
t.Errorf("%s %s: route not found (404)", r.method, r.path)
}
})
}
}

View File

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

@@ -0,0 +1,117 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateToken(t *testing.T) {
tok := GenerateToken()
if len(tok) != 32 {
t.Errorf("expected 32-char token, got %d chars: %s", len(tok), tok)
}
// Must be hex.
for _, c := range tok {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("token contains non-hex char: %c", c)
}
}
}
func TestGenerateToken_Uniqueness(t *testing.T) {
// Two tokens should never collide.
t1 := GenerateToken()
t2 := GenerateToken()
if t1 == t2 {
t.Error("two generated tokens are identical")
}
}
func TestGenerateUUID(t *testing.T) {
uuid := GenerateUUID()
// Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars).
if len(uuid) != 36 {
t.Errorf("expected 36-char UUID, got %d: %s", len(uuid), uuid)
}
// Check dashes at correct positions.
for _, pos := range []int{8, 13, 18, 23} {
if uuid[pos] != '-' {
t.Errorf("expected dash at position %d, got %c", pos, uuid[pos])
}
}
// Version 4: char at position 14 should be '4'.
if uuid[14] != '4' {
t.Errorf("expected version 4 at position 14, got %c", uuid[14])
}
}
func TestGenerateUUID_Uniqueness(t *testing.T) {
u1 := GenerateUUID()
u2 := GenerateUUID()
if u1 == u2 {
t.Error("two generated UUIDs are identical")
}
}
func TestHashPassword(t *testing.T) {
hash, err := HashPassword("testpassword")
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if !strings.HasPrefix(hash, "$2a$") {
t.Errorf("expected bcrypt hash starting with $2a$, got: %s", hash[:4])
}
}
func TestVerifyPassword(t *testing.T) {
hash, err := HashPassword("minecraft123")
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if !VerifyPassword("minecraft123", hash) {
t.Error("VerifyPassword returned false for correct password")
}
if VerifyPassword("wrongpassword", hash) {
t.Error("VerifyPassword returned true for wrong password")
}
}
func TestIsBcryptHash(t *testing.T) {
tests := []struct {
hash string
want bool
}{
{"$2a$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"$2b$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"$2y$10$abcdefghijklmnopqrstuuxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", true},
{"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", false},
{"", false},
{"plaintext", false},
}
for _, tt := range tests {
got := IsBcryptHash(tt.hash)
if got != tt.want {
t.Errorf("IsBcryptHash(%q) = %v, want %v", tt.hash, got, tt.want)
}
}
}
func TestExtractBearer(t *testing.T) {
tests := []struct {
header string
want string
}{
{"Bearer abc123", "abc123"},
{"Bearer ", ""},
{"abc123", ""},
{"", ""},
{"Basic abc123", ""},
}
for _, tt := range tests {
got := ExtractBearer(tt.header)
if got != tt.want {
t.Errorf("ExtractBearer(%q) = %q, want %q", tt.header, got, tt.want)
}
}
}

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

@@ -0,0 +1,68 @@
// package session manages Yggdrasil session lifecycle.
package session
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
)
// StartCleanupWorker launches a background goroutine that deletes expired
// yggdrasil_sessions every interval. It stops when the context is cancelled.
func StartCleanupWorker(db *database.DB, interval time.Duration) context.CancelFunc {
ctx, cancel := context.WithCancel(context.Background())
go func() {
log.Printf("Session cleanup worker started (interval: %v)", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Run once on start.
cleanup(db)
for {
select {
case <-ticker.C:
cleanup(db)
case <-ctx.Done():
log.Println("Session cleanup worker stopped")
return
}
}
}()
return cancel
}
func cleanup(db *database.DB) {
if db == nil {
return
}
pool := db.Pool()
if pool == nil {
return
}
tag, err := pool.Exec(context.Background(),
`DELETE FROM yggdrasil_sessions WHERE expires_at < NOW()`)
if err != nil {
log.Printf("Session cleanup error: %v", err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("Session cleanup: removed %d expired sessions", tag.RowsAffected())
}
}
// CountActive returns the number of non-expired sessions.
func CountActive(pool *pgxpool.Pool) (int, error) {
var count int
err := pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM yggdrasil_sessions WHERE expires_at > NOW()`,
).Scan(&count)
return count, err
}

View File

@@ -0,0 +1,14 @@
package session
import (
"testing"
"time"
)
func TestStartCleanupWorker(t *testing.T) {
// Verify the worker starts and can be cancelled without panic.
cancel := StartCleanupWorker(nil, 1*time.Millisecond)
defer cancel()
// Give it a moment to attempt one cleanup cycle.
time.Sleep(50 * time.Millisecond)
}

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

View 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 &copy; 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>

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

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

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

View 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">320 символов: латиница, цифры, _</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}}

View File

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

@@ -0,0 +1,90 @@
-- 001_init.sql — Initial schema for MrixsCraft server.
-- All tables from the Specification (§3).
-- Enable UUID extension (gen_random_uuid available in PG 13+).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Users table.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(32) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
uuid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
role VARCHAR(16) DEFAULT 'user' CHECK (role IN ('user', 'admin')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Player textures (skin / cape references into CAS).
CREATE TABLE player_textures (
user_id INT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
skin_hash VARCHAR(40),
cape_hash VARCHAR(40),
is_slim BOOLEAN DEFAULT FALSE
);
-- Yggdrasil sessions for launcher and game authentication.
CREATE TABLE yggdrasil_sessions (
client_token UUID NOT NULL DEFAULT gen_random_uuid(),
access_token UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP NOT NULL
);
-- Modpacks (game servers).
CREATE TABLE modpacks (
id SERIAL PRIMARY KEY,
slug VARCHAR(32) UNIQUE NOT NULL,
name VARCHAR(64) NOT NULL,
minecraft_version VARCHAR(16) NOT NULL,
java_version INT NOT NULL CHECK (java_version IN (8, 11, 17, 21)),
server_ip VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Global unique files (CAS registry).
CREATE TABLE global_files (
sha1 VARCHAR(40) PRIMARY KEY CHECK (sha1 ~ '^[0-9a-f]{40}$'),
size_bytes BIGINT NOT NULL CHECK (size_bytes > 0),
file_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(127) DEFAULT 'application/octet-stream',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Launcher releases (populated via CI/CD).
CREATE TABLE launcher_releases (
id SERIAL PRIMARY KEY,
version VARCHAR(32) NOT NULL,
os VARCHAR(16) NOT NULL CHECK (os IN ('windows', 'linux', 'darwin', 'universal')),
arch VARCHAR(16) NOT NULL CHECK (arch IN ('amd64', 'arm64', 'universal')),
sha256 VARCHAR(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
file_path VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_mandatory BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (version, os, arch)
);
-- ── Indexes ──────────────────────────────────────────────────
-- Session look-ups (every authenticated request).
CREATE INDEX idx_sessions_access_token ON yggdrasil_sessions (access_token);
CREATE INDEX idx_sessions_user_id ON yggdrasil_sessions (user_id);
CREATE INDEX idx_sessions_expires_at ON yggdrasil_sessions (expires_at);
-- User look-ups by login fields.
CREATE INDEX idx_users_uuid ON users (uuid);
CREATE INDEX idx_users_username ON users (username);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_role ON users (role);
-- Game client profile look-up.
CREATE INDEX idx_player_textures_skin ON player_textures (skin_hash) WHERE skin_hash IS NOT NULL;
CREATE INDEX idx_player_textures_cape ON player_textures (cape_hash) WHERE cape_hash IS NOT NULL;
-- Launcher latest version look-up.
CREATE INDEX idx_launcher_releases_os_arch ON launcher_releases (os, arch) WHERE is_active = TRUE;
-- CAS file name search.
CREATE INDEX idx_global_files_name ON global_files (file_name);

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

View File

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