538d367cc4
Add internal/assets package that downloads and caches app assets from Hub API with SHA-256 change detection. Assets resolve from synced cache first, falling back to baked-in directory. Daily sync schedule + on-demand POST /api/assets/sync endpoint. Config: assets.sync_enabled + assets.sync_schedule (default 05:00) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1061 lines
35 KiB
Go
1061 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// Router handles all /api/* requests.
|
|
type Router struct {
|
|
cfg *config.Config
|
|
configPath string
|
|
sett *settings.Settings
|
|
stackMgr *stacks.Manager
|
|
syncer *catalogsync.Syncer
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
crossDriveRunner *backup.CrossDriveRunner
|
|
metricsStore *metrics.MetricsStore
|
|
updater *selfupdate.Updater
|
|
notifier *notify.Notifier
|
|
logger *log.Logger
|
|
|
|
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
|
|
OnConfigApplied func()
|
|
|
|
// Asset syncer for on-demand Hub asset sync
|
|
assetsSyncer *assets.Syncer
|
|
}
|
|
|
|
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
|
|
func (r *Router) SetAssetsSyncer(as *assets.Syncer) {
|
|
r.assetsSyncer = as
|
|
}
|
|
|
|
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
|
|
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
|
|
}
|
|
|
|
type apiResponse struct {
|
|
OK bool `json:"ok"`
|
|
Data interface{} `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// ServeHTTP routes /api/* requests.
|
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
path := strings.TrimPrefix(req.URL.Path, "/api")
|
|
path = strings.TrimSuffix(path, "/")
|
|
|
|
switch {
|
|
// GET /api/stacks
|
|
case path == "/stacks" && req.Method == http.MethodGet:
|
|
r.listStacks(w, req)
|
|
|
|
// POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks
|
|
case path == "/stacks/rescan" && req.Method == http.MethodPost:
|
|
r.rescanStacks(w, req)
|
|
|
|
// GET /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
|
|
r.getStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// GET /api/selfupdate/status — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/status" && req.Method == http.MethodGet:
|
|
r.selfupdateStatus(w, req)
|
|
|
|
// POST /api/selfupdate/check — must be before hasSuffix-based stack cases
|
|
case path == "/selfupdate/check" && req.Method == http.MethodPost:
|
|
r.selfupdateCheck(w, req)
|
|
|
|
// POST /api/selfupdate/update — must be before hasSuffix("/update") stack case
|
|
case path == "/selfupdate/update" && req.Method == http.MethodPost:
|
|
r.selfupdateTrigger(w, req)
|
|
|
|
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
|
|
case path == "/config/apply" && req.Method == http.MethodPost:
|
|
r.configApply(w, req)
|
|
|
|
// GET /api/config/hash — return current config file hash
|
|
case path == "/config/hash" && req.Method == http.MethodGet:
|
|
r.configHash(w, req)
|
|
|
|
// GET /api/config — return raw controller.yaml content
|
|
case path == "/config" && req.Method == http.MethodGet:
|
|
r.configContent(w, req)
|
|
|
|
// GET /api/stacks/{name}/deploy-fields
|
|
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
|
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
|
|
|
|
// POST /api/stacks/{name}/deploy
|
|
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
|
|
r.deployStack(w, req, extractName(path, "/deploy"))
|
|
|
|
// POST /api/stacks/{name}/start
|
|
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "start", extractName(path, "/start"))
|
|
|
|
// POST /api/stacks/{name}/stop
|
|
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "stop", extractName(path, "/stop"))
|
|
|
|
// POST /api/stacks/{name}/restart
|
|
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "restart", extractName(path, "/restart"))
|
|
|
|
// POST /api/stacks/{name}/update
|
|
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
|
r.actionStack(w, "update", extractName(path, "/update"))
|
|
|
|
// POST /api/stacks/{name}/optional-config
|
|
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
|
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
|
|
|
|
// GET /api/stacks/{name}/logs
|
|
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
|
|
r.getStackLogs(w, req, extractName(path, "/logs"))
|
|
|
|
// GET /api/stacks/{name}/hdd-data
|
|
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
|
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
|
|
|
// GET /api/stacks/{name}/backup-data
|
|
case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
|
|
r.getStackBackupData(w, req, extractName(path, "/backup-data"))
|
|
|
|
// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
|
|
case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
|
|
r.removeStack(w, req, extractName(path, "/remove"))
|
|
|
|
// DELETE /api/stacks/{name}
|
|
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
|
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
|
|
|
// POST /api/stacks/{name}/cross-backup — save cross-drive config
|
|
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
|
|
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
|
|
|
|
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
|
|
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
|
|
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
|
|
|
|
// GET /api/stacks/{name}/cross-backup/status — poll status
|
|
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
|
|
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
|
|
|
|
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
|
|
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
|
|
r.triggerAllCrossBackups(w, req)
|
|
|
|
// POST /api/sync — trigger immediate catalog sync
|
|
case path == "/sync" && req.Method == http.MethodPost:
|
|
r.triggerSync(w, req)
|
|
|
|
// GET /api/system/info
|
|
case path == "/system/info" && req.Method == http.MethodGet:
|
|
r.systemInfo(w, req)
|
|
|
|
// GET /api/backup/status
|
|
case path == "/backup/status" && req.Method == http.MethodGet:
|
|
r.backupStatus(w, req)
|
|
|
|
// POST /api/backup/run
|
|
case path == "/backup/run" && req.Method == http.MethodPost:
|
|
r.triggerBackup(w, req)
|
|
|
|
// GET /api/backup/snapshots
|
|
case path == "/backup/snapshots" && req.Method == http.MethodGet:
|
|
r.backupSnapshots(w, req)
|
|
|
|
// GET /api/metrics/system
|
|
case path == "/metrics/system" && req.Method == http.MethodGet:
|
|
r.metricsSystem(w, req)
|
|
|
|
// GET /api/metrics/containers/summary
|
|
case path == "/metrics/containers/summary" && req.Method == http.MethodGet:
|
|
r.metricsContainerSummary(w, req)
|
|
|
|
// GET /api/metrics/containers/{name}
|
|
case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet:
|
|
name := strings.TrimPrefix(path, "/metrics/containers/")
|
|
r.metricsContainer(w, req, name)
|
|
|
|
// GET /api/metrics/sysinfo
|
|
case path == "/metrics/sysinfo" && req.Method == http.MethodGet:
|
|
r.metricsSysInfo(w, req)
|
|
|
|
// POST /api/assets/sync — trigger immediate asset sync from Hub
|
|
case path == "/assets/sync" && req.Method == http.MethodPost:
|
|
r.triggerAssetSync(w, req)
|
|
|
|
// GET /api/assets/status — get asset sync status
|
|
case path == "/assets/status" && req.Method == http.MethodGet:
|
|
r.assetSyncStatus(w, req)
|
|
|
|
default:
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
|
|
}
|
|
}
|
|
|
|
// HealthHandler responds to /api/health (no auth required).
|
|
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
|
|
}
|
|
|
|
// --- Stack handlers ---
|
|
|
|
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
|
|
}
|
|
|
|
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
|
|
r.logger.Printf("[API] Manual stack rescan requested")
|
|
if err := r.stackMgr.ScanStacks(); err != nil {
|
|
r.logger.Printf("[API] Stack rescan failed: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
stackCount := len(r.stackMgr.GetStacks())
|
|
r.logger.Printf("[API] Stack rescan completed: %d stacks found", stackCount)
|
|
writeJSON(w, http.StatusOK, apiResponse{
|
|
OK: true,
|
|
Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount),
|
|
})
|
|
}
|
|
|
|
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
|
stack, ok := r.stackMgr.GetStack(name)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
|
}
|
|
|
|
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
|
|
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"metadata": meta,
|
|
"app_config": appCfg,
|
|
"domain": r.cfg.Customer.Domain,
|
|
"logo_url": r.cfg.AppLogoURL(meta.Slug),
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Deploy requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
deployReq := stacks.DeployRequest{
|
|
StackName: name,
|
|
Values: body.Values,
|
|
}
|
|
|
|
warning, err := r.stackMgr.DeployStack(deployReq)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "already deployed") {
|
|
status = http.StatusConflict
|
|
}
|
|
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
|
|
status = http.StatusBadRequest
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
resp := apiResponse{OK: true, Message: "Stack " + name + " deployed"}
|
|
if warning != "" {
|
|
resp.Data = map[string]string{"warning": warning}
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
|
|
// Push app deployed event to Hub
|
|
if r.notifier != nil {
|
|
displayName := name
|
|
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
|
|
displayName = s.Meta.DisplayName
|
|
}
|
|
r.notifier.NotifyAppDeployed(name, displayName)
|
|
}
|
|
}
|
|
|
|
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
|
r.logger.Printf("[API] %s requested for stack: %s", action, name)
|
|
|
|
// Protected stacks only allow restart — block all other actions
|
|
if r.cfg.IsProtectedStack(name) && action != "restart" {
|
|
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)})
|
|
return
|
|
}
|
|
|
|
var err error
|
|
switch action {
|
|
case "start":
|
|
err = r.stackMgr.StartStack(name)
|
|
case "stop":
|
|
err = r.stackMgr.StopStack(name)
|
|
case "restart":
|
|
err = r.stackMgr.RestartStack(name)
|
|
case "update":
|
|
err = r.stackMgr.UpdateStack(name)
|
|
}
|
|
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
|
|
}
|
|
|
|
func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Optional config update requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
|
|
r.logger.Printf("[API] Optional config update failed for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
|
}
|
|
|
|
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
|
|
lines := 100
|
|
if v := req.URL.Query().Get("lines"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
lines = n
|
|
}
|
|
}
|
|
|
|
output, err := r.stackMgr.GetLogs(name, lines)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
|
|
}
|
|
|
|
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
|
|
resp, err := r.stackMgr.GetStackHDDData(name)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
|
|
// Compute the drive path for this stack (HDD or system data path)
|
|
var drivePath string
|
|
if r.crossDriveRunner != nil {
|
|
drivePath = r.crossDriveRunner.GetAppDrivePath(name)
|
|
}
|
|
|
|
resp, err := r.stackMgr.GetStackBackupData(name, drivePath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
|
}
|
|
|
|
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
if name == "" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
|
return
|
|
}
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Remove requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
RemoveBackups bool `json:"remove_backups"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
body.RemoveBackups = false
|
|
}
|
|
|
|
// Compute backup paths to remove if requested
|
|
var backupPaths []string
|
|
if body.RemoveBackups && r.crossDriveRunner != nil {
|
|
drivePath := r.crossDriveRunner.GetAppDrivePath(name)
|
|
if drivePath != "" {
|
|
backupPaths = append(backupPaths,
|
|
backup.AppDBDumpPath(drivePath, name),
|
|
backup.AppSecondaryRsyncPath(drivePath, name),
|
|
)
|
|
}
|
|
}
|
|
|
|
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Remove failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Clean up cross-drive backup config for this stack
|
|
if r.sett != nil {
|
|
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
|
|
r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
|
|
|
// Push app removed event to Hub
|
|
if r.notifier != nil {
|
|
r.notifier.NotifyAppRemoved(name, name)
|
|
}
|
|
}
|
|
|
|
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
|
limitBody(w, req)
|
|
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
|
|
|
var body struct {
|
|
RemoveHDDData bool `json:"remove_hdd_data"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
body.RemoveHDDData = false
|
|
}
|
|
|
|
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
|
|
if err != nil {
|
|
r.logger.Printf("[API] Delete failed for %s: %v", name, err)
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "protected") {
|
|
status = http.StatusForbidden
|
|
}
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
}
|
|
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
|
|
status = http.StatusConflict
|
|
}
|
|
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
|
|
}
|
|
|
|
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
|
r.logger.Println("[API] Manual catalog sync requested")
|
|
result := r.syncer.TriggerSync()
|
|
if !result.OK {
|
|
writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message})
|
|
return
|
|
}
|
|
r.logger.Printf("[API] Catalog sync completed: %s", result.Message)
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result})
|
|
}
|
|
|
|
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
|
|
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
|
|
syncStatus := r.syncer.Status()
|
|
data := map[string]interface{}{
|
|
"system": info,
|
|
"sync_status": syncStatus,
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
// --- Backup handlers ---
|
|
|
|
func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"enabled": false,
|
|
}})
|
|
return
|
|
}
|
|
|
|
dbDump, backupSt := r.backupMgr.GetStatus()
|
|
data := map[string]interface{}{
|
|
"enabled": true,
|
|
"running": r.backupMgr.IsRunning(),
|
|
}
|
|
|
|
if dbDump != nil {
|
|
data["db_dump"] = map[string]interface{}{
|
|
"last_run": dbDump.LastRun,
|
|
"success": dbDump.Success,
|
|
"duration": dbDump.Duration.String(),
|
|
"count": len(dbDump.Results),
|
|
}
|
|
}
|
|
|
|
if backupSt != nil {
|
|
backupData := map[string]interface{}{
|
|
"last_run": backupSt.LastRun,
|
|
"success": backupSt.Success,
|
|
"duration": backupSt.Duration.String(),
|
|
}
|
|
if backupSt.Snapshot != nil {
|
|
backupData["snapshot_id"] = backupSt.Snapshot.SnapshotID
|
|
backupData["files_new"] = backupSt.Snapshot.FilesNew
|
|
backupData["data_added"] = backupSt.Snapshot.DataAdded
|
|
}
|
|
if backupSt.RepoStats != nil {
|
|
backupData["repo_size"] = backupSt.RepoStats.TotalSize
|
|
backupData["snapshot_count"] = backupSt.RepoStats.SnapshotCount
|
|
}
|
|
data["backup"] = backupData
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
|
|
}
|
|
|
|
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
|
return
|
|
}
|
|
|
|
if r.backupMgr.IsRunning() {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
|
return
|
|
}
|
|
|
|
r.logger.Println("[API] Manual backup triggered")
|
|
go r.backupMgr.RunFullBackup(context.Background())
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
|
}
|
|
|
|
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
|
if r.backupMgr == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
|
return
|
|
}
|
|
|
|
snapshots, err := r.backupMgr.ListAllSnapshots(50)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Enrich snapshots with drive labels from storage paths
|
|
if r.sett != nil {
|
|
storagePaths := r.sett.GetStoragePaths()
|
|
for i := range snapshots {
|
|
repoPath := snapshots[i].RepoPath
|
|
for _, sp := range storagePaths {
|
|
if strings.HasPrefix(repoPath, sp.Path) {
|
|
snapshots[i].DriveLabel = sp.Label
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if snapshots == nil {
|
|
snapshots = []backup.SnapshotInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
|
|
}
|
|
|
|
// --- Metrics handlers ---
|
|
|
|
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 200)
|
|
|
|
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Flatten into arrays for Chart.js
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
temp := make([]float64, len(samples))
|
|
load1 := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB
|
|
temp[i] = s.TempCelsius
|
|
load1[i] = s.LoadAvg1
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
"temp": temp,
|
|
"load1": load1,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
|
return
|
|
}
|
|
|
|
summary, err := r.metricsStore.QueryContainerSummary()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary})
|
|
}
|
|
|
|
func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.metricsStore == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}})
|
|
return
|
|
}
|
|
|
|
from, to := parseTimeRange(req)
|
|
resolution := parseResolution(req, 150)
|
|
|
|
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
labels := make([]int64, len(samples))
|
|
cpu := make([]float64, len(samples))
|
|
memory := make([]float64, len(samples))
|
|
|
|
for i, s := range samples {
|
|
labels[i] = s.Timestamp
|
|
cpu[i] = s.CPUPercent
|
|
memory[i] = s.MemUsageMB
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"labels": labels,
|
|
"cpu": cpu,
|
|
"memory": memory,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
|
|
info := metrics.GetStaticInfo()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
|
|
}
|
|
|
|
// --- Cross-drive backup handlers ---
|
|
|
|
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
limitBody(w, req)
|
|
|
|
var body struct {
|
|
Enabled bool `json:"enabled"`
|
|
DestinationPath string `json:"destination_path"`
|
|
Schedule string `json:"schedule"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
|
return
|
|
}
|
|
|
|
// Validate schedule
|
|
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
|
|
return
|
|
}
|
|
// C9: Validate DestinationPath against registered storage paths to prevent path traversal.
|
|
if body.Enabled && body.DestinationPath != "" {
|
|
registeredPaths := r.sett.GetStoragePaths()
|
|
validDest := false
|
|
for _, sp := range registeredPaths {
|
|
if body.DestinationPath == sp.Path {
|
|
validDest = true
|
|
break
|
|
}
|
|
}
|
|
if !validDest {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Preserve existing runtime status
|
|
existing := r.sett.GetCrossDriveConfig(name)
|
|
var lastRun, lastStatus, lastError, lastDuration, lastSize string
|
|
if existing != nil {
|
|
lastRun, lastStatus, lastError, lastDuration, lastSize =
|
|
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
|
|
}
|
|
|
|
cfg := &settings.CrossDriveBackup{
|
|
Enabled: body.Enabled,
|
|
Method: "rsync",
|
|
DestinationPath: body.DestinationPath,
|
|
Schedule: body.Schedule,
|
|
LastRun: lastRun,
|
|
LastStatus: lastStatus,
|
|
LastError: lastError,
|
|
LastDuration: lastDuration,
|
|
LastSizeHuman: lastSize,
|
|
}
|
|
|
|
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
|
|
r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
r.logger.Printf("[API] Cross-drive backup config saved for %s: dest=%s schedule=%s",
|
|
name, body.DestinationPath, body.Schedule)
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
|
|
}
|
|
|
|
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
if r.crossDriveRunner.IsRunning(name) {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
|
return
|
|
}
|
|
|
|
r.logger.Printf("[API] Cross-drive backup triggered for: %s", name)
|
|
go func() {
|
|
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
|
|
r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err)
|
|
}
|
|
}()
|
|
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
|
}
|
|
|
|
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
|
|
cfg := r.sett.GetCrossDriveConfig(name)
|
|
if cfg == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
|
"configured": true,
|
|
"enabled": cfg.Enabled,
|
|
"method": "rsync",
|
|
"schedule": cfg.Schedule,
|
|
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
|
|
"last_run": cfg.LastRun,
|
|
"last_status": cfg.LastStatus,
|
|
"last_error": cfg.LastError,
|
|
"last_duration": cfg.LastDuration,
|
|
"last_size": cfg.LastSizeHuman,
|
|
}})
|
|
}
|
|
|
|
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
|
|
if r.crossDriveRunner == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
|
return
|
|
}
|
|
r.logger.Println("[API] All cross-drive backups triggered")
|
|
go func() {
|
|
ctx := context.Background()
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all error: %v", err)
|
|
}
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
|
}
|
|
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
|
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
|
}
|
|
}()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
|
|
}
|
|
|
|
// parseTimeRange reads range or from/to query params.
|
|
func parseTimeRange(req *http.Request) (from, to time.Time) {
|
|
to = time.Now()
|
|
|
|
if rangeStr := req.URL.Query().Get("range"); rangeStr != "" {
|
|
switch rangeStr {
|
|
case "1h":
|
|
from = to.Add(-1 * time.Hour)
|
|
case "6h":
|
|
from = to.Add(-6 * time.Hour)
|
|
case "24h":
|
|
from = to.Add(-24 * time.Hour)
|
|
case "7d":
|
|
from = to.Add(-7 * 24 * time.Hour)
|
|
case "30d":
|
|
from = to.Add(-30 * 24 * time.Hour)
|
|
default:
|
|
from = to.Add(-24 * time.Hour) // default 24h
|
|
}
|
|
return
|
|
}
|
|
|
|
if fromStr := req.URL.Query().Get("from"); fromStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, fromStr); err == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if toStr := req.URL.Query().Get("to"); toStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, toStr); err == nil {
|
|
to = t
|
|
}
|
|
}
|
|
if from.IsZero() {
|
|
from = to.Add(-24 * time.Hour)
|
|
}
|
|
return
|
|
}
|
|
|
|
// parseResolution reads the resolution query param.
|
|
func parseResolution(req *http.Request, defaultVal int) int {
|
|
if v := req.URL.Query().Get("resolution"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return defaultVal
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
|
|
|
|
func hasSubpath(path, prefix string) bool {
|
|
rest := strings.TrimPrefix(path, prefix)
|
|
return strings.Contains(rest, "/")
|
|
}
|
|
|
|
func trimSegment(path, prefix string) string {
|
|
return strings.TrimPrefix(path, prefix)
|
|
}
|
|
|
|
func extractName(path, suffix string) string {
|
|
s := strings.TrimPrefix(path, "/stacks/")
|
|
name := strings.TrimSuffix(s, suffix)
|
|
// C7: Reject path traversal characters — name is used in file paths and Docker commands.
|
|
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()})
|
|
}
|
|
|
|
func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
result := r.updater.CheckForUpdate()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
|
}
|
|
|
|
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
|
if r.updater == nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
|
return
|
|
}
|
|
if err := r.updater.TriggerUpdate("manual"); err != nil {
|
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
|
|
return
|
|
}
|
|
r.logger.Println("[API] Manual self-update triggered")
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
|
}
|
|
|
|
// --- Config apply handler ---
|
|
|
|
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
|
// Read YAML body (limit to 1MB)
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
|
|
return
|
|
}
|
|
|
|
// Validate it's parseable YAML by attempting to load it
|
|
if _, err := config.LoadFromBytes(body); err != nil {
|
|
r.logger.Printf("[API] Config apply rejected: invalid YAML: %v", err)
|
|
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Write config: try atomic rename first, fall back to direct write
|
|
// (os.Rename fails on Docker bind mounts with "device or resource busy")
|
|
tmpPath := r.configPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, body, 0644); err != nil {
|
|
r.logger.Printf("[ERROR] Config apply: failed to write temp file: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to write config"})
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, r.configPath); err != nil {
|
|
os.Remove(tmpPath)
|
|
// Rename failed (likely Docker bind mount) — write directly
|
|
if err := os.WriteFile(r.configPath, body, 0644); err != nil {
|
|
r.logger.Printf("[ERROR] Config apply: failed to write config: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
|
|
return
|
|
}
|
|
r.logger.Printf("[API] Config apply: rename failed, wrote directly (bind mount)")
|
|
}
|
|
|
|
r.logger.Printf("[API] Config applied from Hub (%d bytes), restart needed to take effect", len(body))
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Config applied. Restart controller to apply changes."})
|
|
|
|
// Push updated infra backup so Hub has fresh config data immediately
|
|
if r.OnConfigApplied != nil {
|
|
go r.OnConfigApplied()
|
|
}
|
|
}
|
|
|
|
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
|
|
hash, err := config.FileHash(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
|
|
}
|
|
|
|
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
|
|
data, err := os.ReadFile(r.configPath)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
|
|
w.Write(data)
|
|
}
|
|
|
|
// --- Asset sync handlers ---
|
|
|
|
func (r *Router) triggerAssetSync(w http.ResponseWriter, req *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: false, Error: "asset sync not configured"})
|
|
return
|
|
}
|
|
r.logger.Println("[API] Manual asset sync requested")
|
|
go func() {
|
|
if err := r.assetsSyncer.Sync(context.Background()); err != nil {
|
|
r.logger.Printf("[WARN] Manual asset sync failed: %v", err)
|
|
}
|
|
}()
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Asset sync started"})
|
|
}
|
|
|
|
func (r *Router) assetSyncStatus(w http.ResponseWriter, _ *http.Request) {
|
|
if r.assetsSyncer == nil {
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"status": "not_configured"}})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.assetsSyncer.Status()})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
log.Printf("[ERROR] Failed to write JSON response: %v", err)
|
|
}
|
|
}
|
|
|
|
// limitBody wraps the request body with a size limit (default 1MB).
|
|
func limitBody(w http.ResponseWriter, req *http.Request) {
|
|
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
|
|
}
|