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>
201 lines
5.8 KiB
Go
201 lines
5.8 KiB
Go
// 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/sha1"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
|
)
|
|
|
|
// mimeByExtension maps common file extensions to MIME types for CAS serving.
|
|
var mimeByExtension = map[string]string{
|
|
".jar": "application/java-archive",
|
|
".json": "application/json",
|
|
".png": "image/png",
|
|
".zip": "application/zip",
|
|
".toml": "application/toml",
|
|
".cfg": "text/plain",
|
|
".conf": "text/plain",
|
|
".txt": "text/plain",
|
|
".log": "text/plain",
|
|
".xml": "application/xml",
|
|
".yml": "application/x-yaml",
|
|
".yaml": "application/x-yaml",
|
|
".properties": "text/plain",
|
|
}
|
|
|
|
// detectContentType returns a MIME type based on the file's extension.
|
|
// Falls back to application/octet-stream for unknown types.
|
|
func detectContentType(fileName string) string {
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
if mime, ok := mimeByExtension[ext]; ok {
|
|
return mime
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
// Handler serves CAS endpoints.
|
|
type Handler struct {
|
|
db *database.DB
|
|
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.
|
|
func StoreFile(casDir string, data []byte) (string, error) {
|
|
hash := sha1Hex(data)
|
|
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 := sha1Hex(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" }
|
|
|
|
func sha1Hex(data []byte) string {
|
|
h := sha1.Sum(data)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
}
|