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
This commit is contained in:
@@ -13,8 +13,11 @@ import (
|
|||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/admin"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/admin"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
"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/config"
|
||||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/middleware"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -33,7 +36,7 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check.
|
// Health check — no auth needed.
|
||||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
@@ -47,16 +50,27 @@ func main() {
|
|||||||
apiHandler := api.NewHandler(db, cfg)
|
apiHandler := api.NewHandler(db, cfg)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// CAS (Content-Addressable Storage) file serving.
|
||||||
|
casHandler := cas.NewHandler(db, cfg)
|
||||||
|
casHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Admin panel.
|
// Admin panel.
|
||||||
adminHandler := admin.NewHandler(db, cfg)
|
adminHandler := admin.NewHandler(db, cfg)
|
||||||
adminHandler.RegisterRoutes(mux)
|
adminHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// TODO: register CAS routes.
|
// Templates (web UI).
|
||||||
|
templatesHandler := templates.NewHandler(db, cfg)
|
||||||
|
templatesHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
// Wrapper chain: Logging → CORS → mux.
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = middleware.CORS(handler)
|
||||||
|
handler = middleware.Logging(handler)
|
||||||
|
|
||||||
addr := ":" + itoa(cfg.Port)
|
addr := ":" + itoa(cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown.
|
// Graceful shutdown.
|
||||||
|
|||||||
@@ -1,2 +1,161 @@
|
|||||||
// package cas implements Content-Addressable Storage for files (mods, assets, etc).
|
// package cas implements Content-Addressable Storage for immutable files.
|
||||||
|
//
|
||||||
|
// Files are stored by SHA-1 hash under <cas_dir>/<first_2_chars>/<full_hash>.
|
||||||
|
// Because files are immutable (changing content changes the hash), long cache
|
||||||
|
// headers are safe and desirable.
|
||||||
package cas
|
package cas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 — also served from CAS-like paths.
|
||||||
|
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).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,159 @@
|
|||||||
// package middleware provides HTTP middleware (JWT, CORS, rate limiting, logging).
|
// package middleware provides HTTP middleware (CORS, logging, rate limiting).
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,96 @@
|
|||||||
// package templates handles Go html/template rendering for the site and admin panel.
|
// package templates handles Go html/template rendering for the site and admin panel.
|
||||||
|
//
|
||||||
|
// This is a placeholder implementation. Actual templates will be added
|
||||||
|
// when the web UI is designed.
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||||
|
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler serves template-rendered pages.
|
||||||
|
type Handler struct {
|
||||||
|
db *database.DB
|
||||||
|
cfg *config.Config
|
||||||
|
layout *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new templates handler and parses embedded/ondisk templates.
|
||||||
|
func NewHandler(db *database.DB, cfg *config.Config) *Handler {
|
||||||
|
h := &Handler{db: db, cfg: cfg}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page handlers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.layout == nil {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("<h1>MrixsCraft</h1>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.layout.ExecuteTemplate(w, "index", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.layout == nil {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("<h1>Login</h1>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.layout.ExecuteTemplate(w, "login", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.layout == nil {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("<h1>Register</h1>"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.layout.ExecuteTemplate(w, "register", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template parsing ───────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) parseTemplates() {
|
||||||
|
// Look for templates in common locations.
|
||||||
|
dirs := []string{
|
||||||
|
filepath.Join("internal", "templates", "html"),
|
||||||
|
"templates",
|
||||||
|
}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tmpl, err := template.ParseGlob(filepath.Join(dir, "*.html"))
|
||||||
|
if err == nil && tmpl != nil {
|
||||||
|
h.layout = tmpl
|
||||||
|
log.Printf("Loaded templates from %s", dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Template parse error in %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No templates found — handler will use placeholder responses.
|
||||||
|
log.Println("No HTML templates found; using placeholder responses")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,89 @@
|
|||||||
// package utils provides shared utility functions (SHA-1, ZIP, etc.).
|
// package utils provides shared utility functions (SHA-1, SHA-256, ZIP, etc.).
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"archive/zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unzip extracts a ZIP archive to the destination directory.
|
||||||
|
// Returns the list of extracted file paths.
|
||||||
|
// Protects against zip-slip by validating that each entry's target path
|
||||||
|
// stays within the destination directory.
|
||||||
|
func Unzip(data []byte, dest string) ([]string, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var extracted []string
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(dest, f.Name)
|
||||||
|
|
||||||
|
// Zip-slip protection.
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
io.Copy(out, rc)
|
||||||
|
out.Close()
|
||||||
|
rc.Close()
|
||||||
|
|
||||||
|
extracted = append(extracted, f.Name)
|
||||||
|
}
|
||||||
|
return extracted, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user