feat: add configurable data directory and file-based logging
Introduce the `WARPBOX_DATA_DIR` environment variable to define where runtime data is stored. This directory will house uploaded files, the bbolt metadata database, and application logs. Changes include: - Added `WARPBOX_DATA_DIR` to configuration, defaulting to `./data`. - Implemented a custom logging package that writes JSONL logs to the data directory. - Updated `.gitignore` and `.env.example` to support the new data directory. - Documented the runtime data structure in `README.md`. - Updated the frontend upload script to handle form submission and display results.
This commit is contained in:
@@ -27,8 +27,11 @@ 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 /d/{boxID}", a.DownloadPage)
|
||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||
mux.HandleFunc("GET /healthz", a.Health)
|
||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||
mux.HandleFunc("POST /api/v1/upload", a.UploadPlaceholder)
|
||||
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
||||
mux.Handle("GET /static/", a.Static())
|
||||
}
|
||||
|
||||
140
backend/libs/handlers/download.go
Normal file
140
backend/libs/handlers/download.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type downloadPageData struct {
|
||||
Box boxView
|
||||
Files []fileView
|
||||
ZipURL string
|
||||
DownloadCount int
|
||||
MaxDownloads int
|
||||
ExpiresLabel string
|
||||
}
|
||||
|
||||
type boxView struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type fileView struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
ContentType string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.renderer.Render(w, http.StatusForbidden, "download.gohtml", web.PageData{
|
||||
Title: "Download unavailable",
|
||||
Description: "This Warpbox link is no longer available.",
|
||||
Data: downloadPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
ExpiresLabel: err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "download.gohtml", 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),
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
path := a.uploadService.FilePath(box, file)
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", file.ContentType)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
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())
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
type uploadPlaceholderResponse struct {
|
||||
Message string `json:"message"`
|
||||
MaxUploadSize string `json:"maxUploadSize"`
|
||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
|
||||
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil {
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
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")),
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "code", 4001, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
func (a *App) UploadPlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||
helpers.WriteJSON(w, http.StatusNotImplemented, uploadPlaceholderResponse{
|
||||
Message: "upload storage is not implemented yet; backend base is ready for the upload pipeline",
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
})
|
||||
func parseInt(value string) int {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func statusForDownloadError(err error) int {
|
||||
if errors.Is(err, http.ErrMissingFile) {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user