feat: add admin console, cleanup, and thumbnail workers
- Implement a token-authenticated admin console at `/admin` with overview metrics and file management. - Add a background worker to periodically clean up expired boxes based on `WARPBOX_CLEANUP_EVERY`. - Add a background worker to generate image and video thumbnails based on `WARPBOX_THUMBNAIL_EVERY`. - Update file storage paths to use `@each@` and `@thumb@` prefixes to separate original files from thumbnails. - Add severity fields to startup logs and update configuration templates.
This commit is contained in:
@@ -11,32 +11,38 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Environment string
|
||||
Addr string
|
||||
BaseURL string
|
||||
DataDir string
|
||||
StaticDir string
|
||||
TemplateDir string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxUploadSize int64
|
||||
AppName string
|
||||
Environment string
|
||||
Addr string
|
||||
BaseURL string
|
||||
DataDir string
|
||||
AdminToken string
|
||||
StaticDir string
|
||||
TemplateDir string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
CleanupEvery time.Duration
|
||||
ThumbnailEvery time.Duration
|
||||
MaxUploadSize int64
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||
Environment: envString("WARPBOX_ENV", "development"),
|
||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||
Environment: envString("WARPBOX_ENV", "development"),
|
||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
||||
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||
}
|
||||
|
||||
if cfg.BaseURL == "" {
|
||||
|
||||
198
backend/libs/handlers/admin.go
Normal file
198
backend/libs/handlers/admin.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
const adminCookieName = "warpbox_admin"
|
||||
|
||||
type adminPageData struct {
|
||||
Stats services.AdminStats
|
||||
Boxes []adminBoxView
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
DownloadCount int
|
||||
MaxDownloads int
|
||||
Protected bool
|
||||
Expired bool
|
||||
}
|
||||
|
||||
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if a.isAdmin(r) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.renderAdminLogin(w, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAdminLogin(w, http.StatusBadRequest, "Unable to read login form.")
|
||||
return
|
||||
}
|
||||
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
|
||||
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
|
||||
a.renderAdminLogin(w, http.StatusUnauthorized, "Invalid admin token.")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
Value: adminCookieValue(a.cfg.AdminToken),
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
Expires: time.Now().Add(12 * time.Hour),
|
||||
})
|
||||
a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301)
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
Value: "",
|
||||
Path: "/admin",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := a.uploadService.AdminStats()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boxes, err := a.adminBoxes(8)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin overview",
|
||||
Description: "Warpbox admin overview.",
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := a.uploadService.AdminStats()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boxes, err := a.adminBoxes(100)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin files",
|
||||
Description: "Manage Warpbox uploads.",
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
boxID := r.PathValue("boxID")
|
||||
if err := a.uploadService.DeleteBox(boxID); err != nil {
|
||||
a.logger.Warn("admin delete failed", "source", "admin", "severity", "warn", "code", 4302, "box_id", boxID, "error", err.Error())
|
||||
http.Error(w, "unable to delete box", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) {
|
||||
a.renderer.Render(w, status, "admin_login.html", web.PageData{
|
||||
Title: "Admin login",
|
||||
Description: "Sign in to the Warpbox admin console.",
|
||||
Data: adminPageData{
|
||||
Error: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
||||
boxes, err := a.uploadService.AdminBoxes(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]adminBoxView, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
rows = append(rows, adminBoxView{
|
||||
ID: box.ID,
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.isAdmin(r) {
|
||||
return true
|
||||
}
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) isAdmin(r *http.Request) bool {
|
||||
if a.cfg.AdminToken == "" {
|
||||
return false
|
||||
}
|
||||
cookie, err := r.Cookie(adminCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
|
||||
}
|
||||
|
||||
func adminCookieValue(token string) string {
|
||||
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -27,9 +27,18 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
||||
|
||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", a.Home)
|
||||
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
||||
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
||||
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
||||
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
||||
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||
mux.HandleFunc("GET /healthz", a.Health)
|
||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
@@ -15,6 +18,9 @@ type downloadPageData struct {
|
||||
Box boxView
|
||||
Files []fileView
|
||||
ZipURL string
|
||||
Locked bool
|
||||
Obfuscated bool
|
||||
CanPreview bool
|
||||
DownloadCount int
|
||||
MaxDownloads int
|
||||
ExpiresLabel string
|
||||
@@ -25,11 +31,21 @@ type boxView struct {
|
||||
}
|
||||
|
||||
type fileView struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
ContentType string
|
||||
URL string
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
ContentType string
|
||||
PreviewKind string
|
||||
URL string
|
||||
DownloadURL string
|
||||
ThumbnailURL string
|
||||
}
|
||||
|
||||
type previewPageData struct {
|
||||
Box boxView
|
||||
File fileView
|
||||
Locked bool
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -39,7 +55,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.renderer.Render(w, http.StatusForbidden, "download.gohtml", web.PageData{
|
||||
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{
|
||||
Title: "Download unavailable",
|
||||
Description: "This Warpbox link is no longer available.",
|
||||
Data: downloadPageData{
|
||||
@@ -49,25 +65,24 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||
|
||||
files := make([]fileView, 0, len(box.Files))
|
||||
for _, file := range box.Files {
|
||||
files = append(files, fileView{
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Size: helpers.FormatBytes(file.Size),
|
||||
ContentType: file.ContentType,
|
||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||
})
|
||||
if !(locked && box.Obfuscate) {
|
||||
for _, file := range box.Files {
|
||||
files = append(files, a.fileView(box, file))
|
||||
}
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "download.gohtml", web.PageData{
|
||||
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
|
||||
Title: "Download files",
|
||||
Description: "Download files shared through Warpbox.",
|
||||
Data: downloadPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
Files: files,
|
||||
ZipURL: fmt.Sprintf("/d/%s/zip", box.ID),
|
||||
Locked: locked,
|
||||
Obfuscated: box.Obfuscate,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
||||
@@ -76,22 +91,114 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||
view := a.fileView(box, file)
|
||||
title := file.Name
|
||||
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
|
||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
||||
if locked && box.Obfuscate {
|
||||
title = "Protected Warpbox file"
|
||||
description = "This shared file is password protected."
|
||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
|
||||
Title: title,
|
||||
Description: description,
|
||||
ImageURL: imageURL,
|
||||
Data: previewPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
File: view,
|
||||
Locked: locked,
|
||||
DownloadURL: view.DownloadURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||
}
|
||||
|
||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
return
|
||||
}
|
||||
|
||||
path := a.uploadService.ThumbnailPath(box, file)
|
||||
if path == "" {
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: unlockCookieName(box.ID),
|
||||
Value: a.uploadService.UnlockToken(box),
|
||||
Path: "/d/" + box.ID,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
Expires: box.ExpiresAt,
|
||||
})
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
|
||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
return box, file, true
|
||||
}
|
||||
|
||||
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||
path := a.uploadService.FilePath(box, file)
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -107,11 +214,13 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", file.ContentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
||||
if attachment {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
||||
}
|
||||
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
|
||||
|
||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.logger.Warn("failed to record file download", "source", "download", "code", 4002, "box_id", box.ID, "error", err.Error())
|
||||
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,16 +234,59 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
|
||||
if err := a.uploadService.WriteZip(w, box); err != nil {
|
||||
a.logger.Error("zip download failed", "source", "download", "code", 5002, "box_id", box.ID, "error", err.Error())
|
||||
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.logger.Warn("failed to record zip download", "source", "download", "code", 4003, "box_id", box.ID, "error", err.Error())
|
||||
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||
return fileView{
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Size: helpers.FormatBytes(file.Size),
|
||||
ContentType: file.ContentType,
|
||||
PreviewKind: file.PreviewKind,
|
||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
|
||||
if !a.uploadService.IsProtected(box) {
|
||||
return true
|
||||
}
|
||||
cookie, err := r.Cookie(unlockCookieName(box.ID))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cookie.Value == a.uploadService.UnlockToken(box)
|
||||
}
|
||||
|
||||
func unlockCookieName(boxID string) string {
|
||||
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
|
||||
}
|
||||
|
||||
func absoluteURL(r *http.Request, path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type homeData struct {
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderer.Render(w, http.StatusOK, "home.gohtml", web.PageData{
|
||||
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
|
||||
Title: "Upload your files",
|
||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||
Data: homeData{
|
||||
|
||||
@@ -18,11 +18,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
files := r.MultipartForm.File["file"]
|
||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||
MaxDays: parseInt(r.FormValue("max_days")),
|
||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||
MaxDays: parseInt(r.FormValue("max_days")),
|
||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||
Password: r.FormValue("password"),
|
||||
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "code", 4001, "error", err.Error())
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpserver
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/handlers"
|
||||
@@ -21,6 +22,8 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stopCleanup := startCleanup(uploadService, cfg.CleanupEvery, logger)
|
||||
stopThumbnails := startThumbnails(uploadService, cfg.ThumbnailEvery, logger)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
@@ -43,10 +46,81 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
}
|
||||
server.RegisterOnShutdown(func() {
|
||||
stopCleanup()
|
||||
stopThumbnails()
|
||||
if err := uploadService.Close(); err != nil {
|
||||
logger.Error("failed to close upload service", "source", "shutdown", "error", err.Error())
|
||||
logger.Error("failed to close upload service", "source", "shutdown", "severity", "error", "error", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func startCleanup(uploadService *services.UploadService, interval time.Duration, logger *slog.Logger) func() {
|
||||
if interval <= 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
if cleaned, err := uploadService.CleanupExpired(); err != nil {
|
||||
logger.Warn("initial cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4201, "error", err.Error())
|
||||
} else if cleaned > 0 {
|
||||
logger.Info("initial cleanup complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if _, err := uploadService.CleanupExpired(); err != nil {
|
||||
logger.Warn("scheduled cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4202, "error", err.Error())
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
func startThumbnails(uploadService *services.UploadService, interval time.Duration, logger *slog.Logger) func() {
|
||||
if interval <= 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
run := func(source string) {
|
||||
result, err := uploadService.GenerateMissingThumbnails()
|
||||
if err != nil {
|
||||
logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if result.Generated > 0 || result.Failed > 0 {
|
||||
logger.Info("thumbnail job run", "source", source, "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
run("thumbnail")
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
run("thumbnail")
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
close(stop)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +94,12 @@ func applyAttr(entry map[string]any, attr slog.Attr) {
|
||||
func severity(level slog.Level) string {
|
||||
switch {
|
||||
case level >= slog.LevelError:
|
||||
return "high"
|
||||
return "error"
|
||||
case level >= slog.LevelWarn:
|
||||
return "medium"
|
||||
return "warn"
|
||||
case level <= slog.LevelDebug:
|
||||
return "low"
|
||||
return "dev"
|
||||
default:
|
||||
return "info"
|
||||
return "dev"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func Logger(logger *slog.Logger) Middleware {
|
||||
|
||||
logger.Info("http request",
|
||||
"source", "http",
|
||||
"severity", "dev",
|
||||
"code", status,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
|
||||
@@ -12,6 +12,9 @@ func Recoverer(logger *slog.Logger) Middleware {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("panic recovered",
|
||||
"source", "panic",
|
||||
"severity", "error",
|
||||
"code", 5001,
|
||||
"error", recovered,
|
||||
"stack", string(debug.Stack()),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
|
||||
@@ -3,18 +3,28 @@ package services
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
)
|
||||
@@ -31,8 +41,10 @@ type UploadService struct {
|
||||
}
|
||||
|
||||
type UploadOptions struct {
|
||||
MaxDays int
|
||||
MaxDownloads int
|
||||
MaxDays int
|
||||
MaxDownloads int
|
||||
Password string
|
||||
ObfuscateMetadata bool
|
||||
}
|
||||
|
||||
type Box struct {
|
||||
@@ -41,6 +53,9 @@ type Box struct {
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
MaxDownloads int `json:"maxDownloads"`
|
||||
DownloadCount int `json:"downloadCount"`
|
||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||
PasswordHash string `json:"passwordHash,omitempty"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
@@ -50,6 +65,8 @@ type File struct {
|
||||
StoredName string `json:"storedName"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
PreviewKind string `json:"previewKind"`
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
UploadedAt time.Time `json:"uploadedAt"`
|
||||
}
|
||||
|
||||
@@ -68,6 +85,36 @@ type ResultFile struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type AdminStats struct {
|
||||
TotalBoxes int
|
||||
TotalFiles int
|
||||
TotalSize int64
|
||||
UploadsLast24H int
|
||||
ExpiredBoxes int
|
||||
ProtectedBoxes int
|
||||
TotalDownloads int
|
||||
TotalSizeLabel string
|
||||
}
|
||||
|
||||
type ThumbnailJobResult struct {
|
||||
Scanned int
|
||||
Generated int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type AdminBox struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
FileCount int
|
||||
TotalSize int64
|
||||
TotalSizeLabel string
|
||||
DownloadCount int
|
||||
MaxDownloads int
|
||||
Protected bool
|
||||
Expired bool
|
||||
}
|
||||
|
||||
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
||||
filesDir := filepath.Join(dataDir, "files")
|
||||
dbDir := filepath.Join(dataDir, "db")
|
||||
@@ -133,8 +180,14 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
||||
MaxDownloads: opts.MaxDownloads,
|
||||
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
||||
Files: make([]File, 0, len(files)),
|
||||
}
|
||||
if strings.TrimSpace(opts.Password) != "" {
|
||||
salt, hash := hashPassword(opts.Password)
|
||||
box.PasswordSalt = salt
|
||||
box.PasswordHash = hash
|
||||
}
|
||||
|
||||
boxDir := filepath.Join(s.filesDir, box.ID)
|
||||
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
||||
@@ -152,7 +205,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
}
|
||||
|
||||
fileID := randomID(8)
|
||||
storedName := fileID + strings.ToLower(filepath.Ext(header.Filename))
|
||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
||||
storedPath := filepath.Join(boxDir, storedName)
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
@@ -171,6 +224,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
StoredName: storedName,
|
||||
Size: header.Size,
|
||||
ContentType: contentType,
|
||||
PreviewKind: previewKind(contentType),
|
||||
UploadedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
@@ -181,6 +235,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
|
||||
s.logger.Info("upload complete",
|
||||
"source", "user-upload",
|
||||
"severity", "user_activity",
|
||||
"code", 2001,
|
||||
"box_id", box.ID,
|
||||
"file_count", len(box.Files),
|
||||
@@ -204,6 +259,173 @@ func (s *UploadService) GetBox(id string) (Box, error) {
|
||||
return box, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
|
||||
boxes := make([]Box, 0)
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
cursor := tx.Bucket(boxesBucket).Cursor()
|
||||
for key, value := cursor.Last(); key != nil; key, value = cursor.Prev() {
|
||||
var box Box
|
||||
if err := json.Unmarshal(value, &box); err != nil {
|
||||
return err
|
||||
}
|
||||
boxes = append(boxes, box)
|
||||
if limit > 0 && len(boxes) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return boxes, err
|
||||
}
|
||||
|
||||
func (s *UploadService) AdminStats() (AdminStats, error) {
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return AdminStats{}, err
|
||||
}
|
||||
|
||||
var stats AdminStats
|
||||
cutoff := time.Now().UTC().Add(-24 * time.Hour)
|
||||
now := time.Now().UTC()
|
||||
for _, box := range boxes {
|
||||
stats.TotalBoxes++
|
||||
stats.TotalDownloads += box.DownloadCount
|
||||
if box.CreatedAt.After(cutoff) {
|
||||
stats.UploadsLast24H++
|
||||
}
|
||||
if box.ExpiresAt.Before(now) {
|
||||
stats.ExpiredBoxes++
|
||||
}
|
||||
if s.IsProtected(box) {
|
||||
stats.ProtectedBoxes++
|
||||
}
|
||||
for _, file := range box.Files {
|
||||
stats.TotalFiles++
|
||||
stats.TotalSize += file.Size
|
||||
}
|
||||
}
|
||||
stats.TotalSizeLabel = helpers.FormatBytes(stats.TotalSize)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
||||
boxes, err := s.ListBoxes(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows := make([]AdminBox, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
var size int64
|
||||
for _, file := range box.Files {
|
||||
size += file.Size
|
||||
}
|
||||
rows = append(rows, AdminBox{
|
||||
ID: box.ID,
|
||||
CreatedAt: box.CreatedAt,
|
||||
ExpiresAt: box.ExpiresAt,
|
||||
FileCount: len(box.Files),
|
||||
TotalSize: size,
|
||||
TotalSizeLabel: helpers.FormatBytes(size),
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: s.IsProtected(box),
|
||||
Expired: box.ExpiresAt.Before(now),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBox(boxID string) error {
|
||||
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("box deleted", "source", "admin", "severity", "user_activity", "code", 2101, "box_id", boxID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) CleanupExpired() (int, error) {
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cleaned := 0
|
||||
for _, box := range boxes {
|
||||
if box.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
if err := s.DeleteBox(box.ID); err != nil {
|
||||
return cleaned, err
|
||||
}
|
||||
cleaned++
|
||||
}
|
||||
if cleaned > 0 {
|
||||
s.logger.Info("expired boxes cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2201, "cleaned", cleaned)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) GenerateMissingThumbnails() (ThumbnailJobResult, error) {
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return ThumbnailJobResult{}, err
|
||||
}
|
||||
|
||||
var result ThumbnailJobResult
|
||||
for _, box := range boxes {
|
||||
if time.Now().UTC().After(box.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i := range box.Files {
|
||||
file := &box.Files[i]
|
||||
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
||||
continue
|
||||
}
|
||||
result.Scanned++
|
||||
|
||||
path := s.FilePath(box, *file)
|
||||
thumbnail := s.generateThumbnail(box.ID, file.ID, path, file.ContentType)
|
||||
if thumbnail == "" {
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
file.Thumbnail = thumbnail
|
||||
changed = true
|
||||
result.Generated++
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := s.saveBox(box); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.Generated > 0 || result.Failed > 0 {
|
||||
s.logger.Info("thumbnail job complete",
|
||||
"source", "thumbnail",
|
||||
"severity", "user_activity",
|
||||
"code", 2203,
|
||||
"scanned", result.Scanned,
|
||||
"generated", result.Generated,
|
||||
"failed", result.Failed,
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
|
||||
for _, file := range box.Files {
|
||||
if file.ID == fileID {
|
||||
@@ -217,6 +439,34 @@ func (s *UploadService) FilePath(box Box, file File) string {
|
||||
return filepath.Join(s.filesDir, box.ID, file.StoredName)
|
||||
}
|
||||
|
||||
func (s *UploadService) ThumbnailPath(box Box, file File) string {
|
||||
if file.Thumbnail == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(s.filesDir, box.ID, file.Thumbnail)
|
||||
}
|
||||
|
||||
func (s *UploadService) BoxMetadataPath(box Box) string {
|
||||
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
|
||||
}
|
||||
|
||||
func (s *UploadService) IsProtected(box Box) bool {
|
||||
return box.PasswordHash != "" && box.PasswordSalt != ""
|
||||
}
|
||||
|
||||
func (s *UploadService) VerifyPassword(box Box, password string) bool {
|
||||
if !s.IsProtected(box) {
|
||||
return true
|
||||
}
|
||||
hash := passwordHash(box.PasswordSalt, password)
|
||||
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.PasswordHash)) == 1
|
||||
}
|
||||
|
||||
func (s *UploadService) UnlockToken(box Box) string {
|
||||
sum := sha256.Sum256([]byte(box.ID + ":" + box.PasswordHash))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *UploadService) CanDownload(box Box) error {
|
||||
if time.Now().UTC().After(box.ExpiresAt) {
|
||||
return fmt.Errorf("box has expired")
|
||||
@@ -245,7 +495,10 @@ func (s *UploadService) RecordDownload(boxID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(boxID), next)
|
||||
if err := bucket.Put([]byte(boxID), next); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeBoxMetadata(box)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -288,7 +541,10 @@ func (s *UploadService) saveBox(box Box) error {
|
||||
}
|
||||
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
|
||||
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeBoxMetadata(box)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,3 +594,112 @@ func randomID(byteCount int) string {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, string) {
|
||||
salt := randomID(18)
|
||||
return salt, passwordHash(salt, password)
|
||||
}
|
||||
|
||||
func passwordHash(salt, password string) string {
|
||||
sum := sha256.Sum256([]byte(salt + ":" + password))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func previewKind(contentType string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
return "video"
|
||||
case strings.HasPrefix(contentType, "audio/"):
|
||||
return "audio"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func needsThumbnail(file File) bool {
|
||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||
}
|
||||
|
||||
func (s *UploadService) generateThumbnail(boxID, fileID, path, contentType string) string {
|
||||
thumbnailName := "@thumb@" + fileID + ".jpg"
|
||||
thumbnailPath := filepath.Join(s.filesDir, boxID, thumbnailName)
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/"):
|
||||
err = createImageThumbnail(path, thumbnailPath)
|
||||
case strings.HasPrefix(contentType, "video/"):
|
||||
err = createVideoThumbnail(path, thumbnailPath)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", fileID, "error", err.Error())
|
||||
return ""
|
||||
}
|
||||
return thumbnailName
|
||||
}
|
||||
|
||||
func createImageThumbnail(sourcePath, targetPath string) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
img, _, err := image.Decode(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thumb := resizeNearest(img, 360, 240)
|
||||
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
|
||||
}
|
||||
|
||||
func createVideoThumbnail(sourcePath, targetPath string) error {
|
||||
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run()
|
||||
}
|
||||
|
||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||
bounds := src.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
}
|
||||
|
||||
scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
}
|
||||
targetWidth := max(1, int(float64(width)*scale))
|
||||
targetHeight := max(1, int(float64(height)*scale))
|
||||
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||
|
||||
for y := 0; y < targetHeight; y++ {
|
||||
for x := 0; x < targetWidth; x++ {
|
||||
srcX := bounds.Min.X + int(float64(x)/scale)
|
||||
srcY := bounds.Min.Y + int(float64(y)/scale)
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func (s *UploadService) writeBoxMetadata(box Box) error {
|
||||
path := s.BoxMetadataPath(box)
|
||||
data, err := json.MarshalIndent(box, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
@@ -18,22 +18,23 @@ type PageData struct {
|
||||
BaseURL string
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
CurrentYear int
|
||||
Data any
|
||||
}
|
||||
|
||||
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.gohtml"))
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
|
||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user