diff --git a/.env.example b/.env.example index dd607cf..406cdb1 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ WARPBOX_APP_NAME=warpbox.dev WARPBOX_ENV=development WARPBOX_ADDR=:8080 WARPBOX_BASE_URL=http://localhost:8080 +WARPBOX_DATA_DIR=./data WARPBOX_MAX_UPLOAD_SIZE_MB=2048 WARPBOX_READ_TIMEOUT=15s WARPBOX_WRITE_TIMEOUT=60s diff --git a/.gitignore b/.gitignore index e0652c8..3499abf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ bin/ *.out # Local runtime data +data/ backend/static/uploads/* !backend/static/uploads/.gitkeep diff --git a/README.md b/README.md index fd1c5df..9dc0c29 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ The default server listens on `:8080`. Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`. Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB. +Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. +The dev script resolves that path from the repository root. + For one-off Go commands, run them from the backend module: ```bash @@ -36,6 +39,14 @@ go run ./cmd/warpbox - `scripts/env/dev.env.example` - tracked development environment template. - `scripts/env/dev.env` - local development environment, ignored by git. +## Runtime Data + +Warpbox keeps local runtime data under the configured data directory: + +- `data/files/{box_id}/{file_id}.ext` - uploaded file contents. +- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records. +- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line. + ## Static Asset Policy -The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter caching for CSS/JS, and gzip compression for compressible responses. Upload storage is intentionally only a placeholder at this stage. +The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter caching for CSS/JS, and gzip compression for compressible responses. diff --git a/backend/cmd/warpbox/main.go b/backend/cmd/warpbox/main.go index 055bb48..0cdc443 100644 --- a/backend/cmd/warpbox/main.go +++ b/backend/cmd/warpbox/main.go @@ -3,7 +3,6 @@ package main import ( "context" "errors" - "log/slog" "net/http" "os" "os/signal" @@ -12,28 +11,32 @@ import ( "warpbox.dev/backend/libs/config" "warpbox.dev/backend/libs/httpserver" + "warpbox.dev/backend/libs/logging" ) func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - cfg, err := config.Load() if err != nil { - logger.Error("failed to load config", "error", err) + os.Stderr.WriteString("failed to load config: " + err.Error() + "\n") os.Exit(1) } + logger, closeLogs, err := logging.New(cfg.DataDir) + if err != nil { + os.Stderr.WriteString("failed to create logger: " + err.Error() + "\n") + os.Exit(1) + } + defer closeLogs() + server, err := httpserver.New(cfg, logger) if err != nil { - logger.Error("failed to create server", "error", err) + logger.Error("failed to create server", "source", "startup", "error", err.Error()) os.Exit(1) } errs := make(chan error, 1) go func() { - logger.Info("warpbox server starting", "addr", cfg.Addr, "env", cfg.Environment) + logger.Info("warpbox server starting", "source", "startup", "addr", cfg.Addr, "env", cfg.Environment) errs <- server.ListenAndServe() }() @@ -43,18 +46,18 @@ func main() { select { case err := <-errs: if err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Error("server stopped unexpectedly", "error", err) + logger.Error("server stopped unexpectedly", "source", "startup", "error", err.Error()) os.Exit(1) } case sig := <-shutdown: - logger.Info("shutdown signal received", "signal", sig.String()) + logger.Info("shutdown signal received", "source", "startup", "signal", sig.String()) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { - logger.Error("graceful shutdown failed", "error", err) + logger.Error("graceful shutdown failed", "source", "startup", "error", err.Error()) os.Exit(1) } - logger.Info("server stopped") + logger.Info("server stopped", "source", "startup") } } diff --git a/backend/go.mod b/backend/go.mod index 0607694..0c2ccfc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,7 @@ module warpbox.dev/backend go 1.26 + +require go.etcd.io/bbolt v1.4.3 + +require golang.org/x/sys v0.29.0 // indirect diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..56ca229 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index 35729df..807f5c4 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -15,6 +15,7 @@ type Config struct { Environment string Addr string BaseURL string + DataDir string StaticDir string TemplateDir string ReadTimeout time.Duration @@ -29,6 +30,7 @@ func Load() (Config, error) { 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), diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 89cc80b..a9c0c26 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -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()) } diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go new file mode 100644 index 0000000..df9c7f5 --- /dev/null +++ b/backend/libs/handlers/download.go @@ -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()) + } +} diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index c105421..76fb7ad 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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 } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index ecaae19..b8504bc 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -17,7 +17,10 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { return nil, err } - uploadService := services.NewUploadService(cfg.MaxUploadSize) + uploadService, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger) + if err != nil { + return nil, err + } app := handlers.NewApp(cfg, logger, renderer, uploadService) router := http.NewServeMux() @@ -32,11 +35,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { middleware.Logger(logger), ) - return &http.Server{ + server := &http.Server{ Addr: cfg.Addr, Handler: handler, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout, - }, nil + } + server.RegisterOnShutdown(func() { + if err := uploadService.Close(); err != nil { + logger.Error("failed to close upload service", "source", "shutdown", "error", err.Error()) + } + }) + + return server, nil } diff --git a/backend/libs/logging/logger.go b/backend/libs/logging/logger.go new file mode 100644 index 0000000..0da1e7c --- /dev/null +++ b/backend/libs/logging/logger.go @@ -0,0 +1,105 @@ +package logging + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "time" +) + +type Closer func() error + +type jsonLineHandler struct { + mu *sync.Mutex + out io.Writer + attrs []slog.Attr +} + +func New(dataDir string) (*slog.Logger, Closer, error) { + logDir := filepath.Join(dataDir, "logs") + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, nil, err + } + + path := filepath.Join(logDir, time.Now().Format("2006-01-02")+".log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return nil, nil, err + } + + handler := &jsonLineHandler{ + mu: &sync.Mutex{}, + out: io.MultiWriter(os.Stdout, file), + } + + return slog.New(handler), file.Close, nil +} + +func (h *jsonLineHandler) Enabled(context.Context, slog.Level) bool { + return true +} + +func (h *jsonLineHandler) Handle(_ context.Context, record slog.Record) error { + entry := map[string]any{ + "date": record.Time.Format("2006-01-02"), + "time": record.Time.Format("15:04:05"), + "source": "app", + "code": 0, + "severity": severity(record.Level), + "log": record.Message, + } + + for _, attr := range h.attrs { + applyAttr(entry, attr) + } + record.Attrs(func(attr slog.Attr) bool { + applyAttr(entry, attr) + return true + }) + + line, err := json.Marshal(entry) + if err != nil { + return err + } + + h.mu.Lock() + defer h.mu.Unlock() + _, err = h.out.Write(append(line, '\n')) + return err +} + +func (h *jsonLineHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + next := &jsonLineHandler{ + mu: h.mu, + out: h.out, + attrs: append([]slog.Attr{}, h.attrs...), + } + next.attrs = append(next.attrs, attrs...) + return next +} + +func (h *jsonLineHandler) WithGroup(string) slog.Handler { + return h +} + +func applyAttr(entry map[string]any, attr slog.Attr) { + attr.Value = attr.Value.Resolve() + entry[attr.Key] = attr.Value.Any() +} + +func severity(level slog.Level) string { + switch { + case level >= slog.LevelError: + return "high" + case level >= slog.LevelWarn: + return "medium" + case level <= slog.LevelDebug: + return "low" + default: + return "info" + } +} diff --git a/backend/libs/middleware/logger.go b/backend/libs/middleware/logger.go index 19ebc6d..63af3fc 100644 --- a/backend/libs/middleware/logger.go +++ b/backend/libs/middleware/logger.go @@ -40,6 +40,8 @@ func Logger(logger *slog.Logger) Middleware { } logger.Info("http request", + "source", "http", + "code", status, "method", r.Method, "path", r.URL.Path, "status", status, diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 176bc44..1d603b7 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -1,17 +1,108 @@ package services import ( + "archive/zip" + "crypto/rand" + "encoding/base64" + "encoding/json" "fmt" + "io" + "log/slog" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "go.etcd.io/bbolt" "warpbox.dev/backend/libs/helpers" ) +var boxesBucket = []byte("boxes") + type UploadService struct { maxUploadSize int64 + baseURL string + dataDir string + filesDir string + db *bbolt.DB + logger *slog.Logger } -func NewUploadService(maxUploadSize int64) *UploadService { - return &UploadService{maxUploadSize: maxUploadSize} +type UploadOptions struct { + MaxDays int + MaxDownloads int +} + +type Box struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + MaxDownloads int `json:"maxDownloads"` + DownloadCount int `json:"downloadCount"` + Files []File `json:"files"` +} + +type File struct { + ID string `json:"id"` + Name string `json:"name"` + StoredName string `json:"storedName"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + UploadedAt time.Time `json:"uploadedAt"` +} + +type UploadResult struct { + BoxID string `json:"boxId"` + BoxURL string `json:"boxUrl"` + ZipURL string `json:"zipUrl"` + ExpiresAt string `json:"expiresAt"` + Files []ResultFile `json:"files"` +} + +type ResultFile struct { + ID string `json:"id"` + Name string `json:"name"` + Size string `json:"size"` + URL string `json:"url"` +} + +func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { + filesDir := filepath.Join(dataDir, "files") + dbDir := filepath.Join(dataDir, "db") + if err := os.MkdirAll(filesDir, 0o755); err != nil { + return nil, err + } + if err := os.MkdirAll(dbDir, 0o755); err != nil { + return nil, err + } + + db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second}) + if err != nil { + return nil, err + } + + if err := db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists(boxesBucket) + return err + }); err != nil { + db.Close() + return nil, err + } + + return &UploadService{ + maxUploadSize: maxUploadSize, + baseURL: strings.TrimRight(baseURL, "/"), + dataDir: dataDir, + filesDir: filesDir, + db: db, + logger: logger, + }, nil +} + +func (s *UploadService) Close() error { + return s.db.Close() } func (s *UploadService) MaxUploadSize() int64 { @@ -28,3 +119,222 @@ func (s *UploadService) ValidateSize(size int64) error { } return nil } + +func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) { + if len(files) == 0 { + return UploadResult{}, fmt.Errorf("no files were uploaded") + } + if opts.MaxDays <= 0 { + opts.MaxDays = 7 + } + + box := Box{ + ID: randomID(10), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), + MaxDownloads: opts.MaxDownloads, + Files: make([]File, 0, len(files)), + } + + boxDir := filepath.Join(s.filesDir, box.ID) + if err := os.MkdirAll(boxDir, 0o755); err != nil { + return UploadResult{}, err + } + + for _, header := range files { + if err := s.ValidateSize(header.Size); err != nil { + return UploadResult{}, err + } + + file, err := header.Open() + if err != nil { + return UploadResult{}, err + } + + fileID := randomID(8) + storedName := fileID + strings.ToLower(filepath.Ext(header.Filename)) + storedPath := filepath.Join(boxDir, storedName) + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil { + file.Close() + return UploadResult{}, err + } + file.Close() + + box.Files = append(box.Files, File{ + ID: fileID, + Name: filepath.Base(header.Filename), + StoredName: storedName, + Size: header.Size, + ContentType: contentType, + UploadedAt: time.Now().UTC(), + }) + } + + if err := s.saveBox(box); err != nil { + return UploadResult{}, err + } + + s.logger.Info("upload complete", + "source", "user-upload", + "code", 2001, + "box_id", box.ID, + "file_count", len(box.Files), + ) + + return s.resultForBox(box), nil +} + +func (s *UploadService) GetBox(id string) (Box, error) { + var box Box + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(boxesBucket).Get([]byte(id)) + if data == nil { + return os.ErrNotExist + } + return json.Unmarshal(data, &box) + }) + if err != nil { + return Box{}, err + } + return box, nil +} + +func (s *UploadService) FindFile(box Box, fileID string) (File, error) { + for _, file := range box.Files { + if file.ID == fileID { + return file, nil + } + } + return File{}, os.ErrNotExist +} + +func (s *UploadService) FilePath(box Box, file File) string { + return filepath.Join(s.filesDir, box.ID, file.StoredName) +} + +func (s *UploadService) CanDownload(box Box) error { + if time.Now().UTC().After(box.ExpiresAt) { + return fmt.Errorf("box has expired") + } + if box.MaxDownloads > 0 && box.DownloadCount >= box.MaxDownloads { + return fmt.Errorf("download limit reached") + } + return nil +} + +func (s *UploadService) RecordDownload(boxID string) error { + return s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(boxesBucket) + data := bucket.Get([]byte(boxID)) + if data == nil { + return os.ErrNotExist + } + + var box Box + if err := json.Unmarshal(data, &box); err != nil { + return err + } + box.DownloadCount++ + + next, err := json.Marshal(box) + if err != nil { + return err + } + return bucket.Put([]byte(boxID), next) + }) +} + +func (s *UploadService) WriteZip(w io.Writer, box Box) error { + archive := zip.NewWriter(w) + defer archive.Close() + + for _, file := range box.Files { + path := s.FilePath(box, file) + source, err := os.Open(path) + if err != nil { + return err + } + + header := &zip.FileHeader{ + Name: file.Name, + Method: zip.Deflate, + Modified: file.UploadedAt, + } + target, err := archive.CreateHeader(header) + if err != nil { + source.Close() + return err + } + + if _, err := io.Copy(target, source); err != nil { + source.Close() + return err + } + source.Close() + } + + return nil +} + +func (s *UploadService) saveBox(box Box) error { + data, err := json.Marshal(box) + if err != nil { + return err + } + + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(boxesBucket).Put([]byte(box.ID), data) + }) +} + +func (s *UploadService) resultForBox(box Box) UploadResult { + files := make([]ResultFile, 0, len(box.Files)) + for _, file := range box.Files { + files = append(files, ResultFile{ + ID: file.ID, + Name: file.Name, + Size: helpers.FormatBytes(file.Size), + URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID), + }) + } + + return UploadResult{ + BoxID: box.ID, + BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID), + ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID), + ExpiresAt: box.ExpiresAt.Format(time.RFC3339), + Files: files, + } +} + +func writeUploadedFile(path string, source multipart.File, maxSize int64) error { + target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer target.Close() + + written, err := io.Copy(target, io.LimitReader(source, maxSize+1)) + if err != nil { + os.Remove(path) + return err + } + if written > maxSize { + os.Remove(path) + return fmt.Errorf("file exceeds max upload size") + } + return nil +} + +func randomID(byteCount int) string { + data := make([]byte, byteCount) + if _, err := rand.Read(data); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/backend/static/css/app.css b/backend/static/css/app.css index a9bd632..4473adb 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -1,18 +1,21 @@ :root { - color-scheme: light; - --background: #ffffff; - --foreground: #09090b; - --card: #ffffff; - --card-foreground: #09090b; - --muted: #f4f4f5; - --muted-foreground: #71717a; - --border: #e4e4e7; - --input: #e4e4e7; - --primary: #18181b; - --primary-foreground: #fafafa; - --ring: #a1a1aa; - --radius: 0.5rem; - --shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + color-scheme: dark; + --background: #09090b; + --foreground: #fafafa; + --card: #18181b; + --card-foreground: #fafafa; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --accent: #27272a; + --accent-foreground: #fafafa; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --primary: #f4f4f5; + --primary-foreground: #18181b; + --ring: #71717a; + --success: #86efac; + --radius: 0.625rem; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.45); } * { @@ -30,13 +33,25 @@ body { margin: 0; display: flex; flex-direction: column; - background: radial-gradient(circle at top, #fafafa 0, var(--background) 34rem); + background: + radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), + linear-gradient(180deg, #09090b 0%, #0f0f12 100%); } a { color: inherit; } +svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; @@ -59,13 +74,13 @@ a { .site-header { border-bottom: 1px solid var(--border); - background: rgba(255, 255, 255, 0.84); + background: rgba(9, 9, 11, 0.84); backdrop-filter: blur(14px); } .nav { - width: min(1180px, calc(100% - 2rem)); - min-height: 4.5rem; + width: min(72rem, calc(100% - 2rem)); + min-height: 3.5rem; margin: 0 auto; display: flex; align-items: center; @@ -74,31 +89,28 @@ a { } .brand, -.nav-links { +.nav-links, +.footer-links { display: inline-flex; align-items: center; - gap: 0.7rem; + gap: 0.5rem; } .brand { - font-weight: 800; + font-weight: 650; text-decoration: none; - letter-spacing: 0; } .brand-mark { - width: 2rem; - height: 2rem; + width: 1.75rem; + height: 1.75rem; display: grid; place-items: center; border-radius: calc(var(--radius) - 0.125rem); background: var(--primary); color: var(--primary-foreground); - font-size: 0.9rem; -} - -.nav-links a { - text-decoration: none; + font-size: 0.85rem; + font-weight: 800; } main { @@ -106,57 +118,60 @@ main { } .upload-view { - width: min(620px, calc(100% - 2rem)); - min-height: calc(100vh - 9rem); + width: min(48rem, calc(100% - 2rem)); + min-height: calc(100vh - 7.25rem); margin: 0 auto; - padding: 3rem 0; + padding: 2.5rem 0; display: flex; - align-items: center; + flex-direction: column; justify-content: center; + gap: 1.5rem; +} + +.hero-copy { + text-align: center; } h1 { margin: 0; - color: var(--card-foreground); - font-size: 1.8rem; - line-height: 1.15; - font-weight: 700; -} - -.panel-header { - display: grid; - gap: 0.5rem; - text-align: center; + color: var(--foreground); + font-size: 2rem; + line-height: 1.12; + font-weight: 650; + letter-spacing: 0; } +.hero-copy p, +.download-subtitle, .panel-header p { - margin: 0; + margin: 0.55rem 0 0; color: var(--muted-foreground); font-size: 0.95rem; line-height: 1.5; } -.upload-panel { +.card { width: 100%; - display: grid; - gap: 1rem; - padding: 1.25rem; border: 1px solid var(--border); border-radius: var(--radius); - background: var(--card); + background: color-mix(in srgb, var(--card) 94%, transparent); box-shadow: var(--shadow); } +.card-content { + padding: 1.5rem; +} + .drop-zone { - min-height: 17rem; + min-height: 19rem; display: grid; place-items: center; align-content: center; gap: 0.65rem; padding: 2rem; - border: 1px dashed var(--input); + border: 2px dashed var(--border); border-radius: var(--radius); - background: var(--muted); + background: rgba(39, 39, 42, 0.42); text-align: center; cursor: pointer; transition: border-color 160ms ease, background 160ms ease; @@ -165,7 +180,7 @@ h1 { .drop-zone:hover, .drop-zone.is-dragging { border-color: var(--primary); - background: #fafafa; + background: rgba(39, 39, 42, 0.68); } .drop-zone input { @@ -177,16 +192,16 @@ h1 { } .drop-icon { - width: 3rem; - height: 3rem; + width: 2.75rem; + height: 2.75rem; display: grid; place-items: center; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--background); - color: var(--foreground); - font-size: 2rem; - font-weight: 400; + color: var(--muted-foreground); +} + +.drop-icon svg { + width: 2.5rem; + height: 2.5rem; } .drop-title { @@ -194,22 +209,33 @@ h1 { font-weight: 650; } -.drop-copy { +.drop-copy, +.drop-meta { color: var(--muted-foreground); font-size: 0.9rem; } +.drop-meta { + margin-top: 0.75rem; + font-size: 0.78rem; +} + .advanced-options { + margin-top: 1rem; border: 1px solid var(--border); border-radius: var(--radius); - padding: 0.8rem 0.9rem; + background: rgba(39, 39, 42, 0.28); + padding: 0.75rem 0.9rem; } .advanced-options summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; color: var(--foreground); cursor: pointer; - font-size: 0.9rem; - font-weight: 650; + font-size: 0.875rem; + font-weight: 600; } .option-grid { @@ -221,9 +247,9 @@ h1 { label span { display: block; - margin-bottom: 0.35rem; + margin-bottom: 0.4rem; color: var(--foreground); - font-size: 0.85rem; + font-size: 0.8rem; font-weight: 600; } @@ -239,7 +265,7 @@ select { min-height: 2.25rem; border: 1px solid var(--input); border-radius: calc(var(--radius) - 0.125rem); - padding: 0.6rem 0.75rem; + padding: 0.45rem 0.7rem; background: var(--background); color: var(--foreground); } @@ -248,17 +274,25 @@ input::placeholder { color: var(--muted-foreground); } -.form-footer { +input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.form-footer, +.result-header { + margin-top: 1rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.form-footer p { +.form-footer p, +#result-meta { margin: 0; color: var(--muted-foreground); - font-size: 0.85rem; + font-size: 0.82rem; } .button, @@ -267,15 +301,17 @@ button { display: inline-flex; align-items: center; justify-content: center; + gap: 0.45rem; border: 1px solid transparent; border-radius: calc(var(--radius) - 0.125rem); padding: 0.45rem 0.85rem; color: var(--foreground); background: transparent; font: inherit; - font-size: 0.9rem; + font-size: 0.875rem; font-weight: 600; line-height: 1; + text-decoration: none; cursor: pointer; } @@ -285,7 +321,7 @@ button { } .button-primary:hover { - background: #27272a; + background: #e4e4e7; } .button-outline { @@ -295,35 +331,224 @@ button { .button-outline:hover, .button-ghost:hover { + background: var(--accent); +} + +.button-wide { + width: 100%; + min-height: 2.75rem; + margin-top: 1.25rem; +} + +.upload-progress { + margin-top: 1rem; +} + +.progress-row { + display: flex; + justify-content: space-between; + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.progress { + height: 0.4rem; + margin-top: 0.55rem; + overflow: hidden; + border-radius: 999px; background: var(--muted); } -.site-footer { - width: min(1180px, calc(100% - 2rem)); +.progress span { + display: block; + width: 100%; + height: 100%; + background: var(--primary); + transform-origin: left center; + animation: progress-pulse 1.1s ease-in-out infinite; +} + +.upload-result { + border-color: rgba(244, 244, 245, 0.24); + background: rgba(244, 244, 245, 0.06); +} + +.result-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 650; +} + +.result-title svg { + color: var(--success); +} + +.result-actions { + display: flex; + gap: 0.5rem; +} + +.result-list, +.download-list { + display: grid; + gap: 0.6rem; + margin-top: 1rem; +} + +.result-item, +.download-item { + display: flex; + align-items: center; + gap: 0.8rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--background); + padding: 0.75rem; +} + +.result-item > span, +.download-item > span { + min-width: 0; + flex: 1; +} + +.result-item strong, +.download-item strong, +code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-item small, +.download-item small, +code { + margin-top: 0.25rem; + color: var(--muted-foreground); + font-size: 0.78rem; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} + +.download-view { + width: min(38rem, calc(100% - 2rem)); + min-height: calc(100vh - 7.25rem); margin: 0 auto; - padding: 1.5rem 0; + padding: 2.5rem 0; + display: grid; + place-items: center; +} + +.download-card { + text-align: center; +} + +.file-emblem { + width: 4rem; + height: 4rem; + margin: 0 auto 1rem; + display: grid; + place-items: center; + border-radius: var(--radius); + background: var(--muted); + color: var(--muted-foreground); +} + +.file-emblem svg { + width: 1.75rem; + height: 1.75rem; +} + +.badge-row { + margin-top: 1rem; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 1.5rem; + border-radius: 999px; + background: var(--muted); + color: var(--muted-foreground); + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; +} + +.download-item { + color: var(--foreground); + text-align: left; + text-decoration: none; +} + +.site-footer { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 1rem 0; display: flex; justify-content: space-between; gap: 1rem; color: var(--muted-foreground); - font-size: 0.85rem; + font-size: 0.78rem; } -@media (max-width: 840px) { - .upload-view { - min-height: calc(100vh - 9rem); - padding: 2.5rem 0; +.footer-links a { + text-decoration: none; +} + +@keyframes progress-pulse { + 0% { + transform: scaleX(0.12); + } + 50% { + transform: scaleX(0.72); + } + 100% { + transform: scaleX(1); + } +} + +@media (max-width: 720px) { + .nav-links { + display: none; + } + + .upload-view, + .download-view { + min-height: auto; + padding: 2rem 0; } .option-grid, .form-footer, + .result-header, .site-footer { grid-template-columns: 1fr; flex-direction: column; align-items: stretch; } + .result-actions { + width: 100%; + } + + .result-actions .button { + flex: 1; + } + + h1 { + font-size: 1.65rem; + } + .drop-zone { - min-height: 14rem; + min-height: 15rem; } } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 20bf8f2..ca40bf2 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -1,11 +1,22 @@ (function () { + const form = document.querySelector("#upload-form"); const dropZone = document.querySelector(".drop-zone"); const fileInput = document.querySelector("#file-input"); + const fileSummary = document.querySelector("#file-summary"); + const progress = document.querySelector("#upload-progress"); + const uploadStatus = document.querySelector("#upload-status"); + const result = document.querySelector("#upload-result"); + const resultMeta = document.querySelector("#result-meta"); + const resultList = document.querySelector("#result-list"); + const copyAll = document.querySelector("#copy-all"); + const openBox = document.querySelector("#open-box"); - if (!dropZone || !fileInput) { + if (!form || !dropZone || !fileInput) { return; } + let latestLinks = []; + ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, (event) => { event.preventDefault(); @@ -23,20 +34,153 @@ dropZone.addEventListener("drop", (event) => { if (event.dataTransfer && event.dataTransfer.files.length > 0) { fileInput.files = event.dataTransfer.files; - updateDropLabel(event.dataTransfer.files.length); + updateSelectedState(event.dataTransfer.files); } }); fileInput.addEventListener("change", () => { - updateDropLabel(fileInput.files.length); + updateSelectedState(fileInput.files); }); - function updateDropLabel(count) { - const title = dropZone.querySelector(".drop-title"); - if (!title) { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!fileInput.files || fileInput.files.length === 0) { + updateStatus("Choose at least one file first."); return; } - title.textContent = count === 1 ? "1 file selected" : `${count} files selected`; + const submit = form.querySelector("button[type='submit']"); + const formData = new FormData(form); + setLoading(true, submit); + + try { + const response = await fetch(form.action, { + method: "POST", + body: formData, + headers: { + Accept: "application/json", + }, + }); + + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "Upload failed"); + } + + renderResult(payload); + form.reset(); + updateSelectedState([]); + } catch (error) { + updateStatus(error.message || "Upload failed"); + } finally { + setLoading(false, submit); + } + }); + + if (copyAll) { + copyAll.addEventListener("click", () => { + copyText(latestLinks.join("\n"), copyAll, "Copied"); + }); + } + + function updateSelectedState(files) { + const count = files.length || 0; + const title = dropZone.querySelector(".drop-title"); + if (title) { + title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`; + } + if (fileSummary) { + fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; + } + } + + function setLoading(isLoading, submit) { + if (progress) { + progress.hidden = !isLoading; + } + if (submit) { + submit.disabled = isLoading; + submit.textContent = isLoading ? "Uploading..." : "Upload files"; + } + updateStatus(isLoading ? "Transferring files..." : ""); + } + + function updateStatus(message) { + if (uploadStatus) { + uploadStatus.textContent = message; + } + } + + function renderResult(payload) { + if (!result || !resultList || !resultMeta || !openBox) { + return; + } + + latestLinks = [payload.boxUrl, payload.zipUrl].concat(payload.files.map((file) => file.url)); + result.hidden = false; + openBox.href = payload.boxUrl; + resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`; + + resultList.replaceChildren(); + payload.files.forEach((file) => { + const row = document.createElement("div"); + row.className = "result-item"; + + const body = document.createElement("span"); + const name = document.createElement("strong"); + name.textContent = file.name; + const url = document.createElement("code"); + url.textContent = file.url; + body.append(name, url); + + const copy = document.createElement("button"); + copy.className = "button button-outline"; + copy.type = "button"; + copy.textContent = "Copy"; + copy.addEventListener("click", () => copyText(file.url, copy, "Copied")); + + row.append(body, copy); + resultList.append(row); + }); + + const zip = document.createElement("div"); + zip.className = "result-item"; + const zipBody = document.createElement("span"); + const zipName = document.createElement("strong"); + zipName.textContent = "Download all as zip"; + const zipUrl = document.createElement("code"); + zipUrl.textContent = payload.zipUrl; + zipBody.append(zipName, zipUrl); + const zipCopy = document.createElement("button"); + zipCopy.className = "button button-outline"; + zipCopy.type = "button"; + zipCopy.textContent = "Copy"; + zipCopy.addEventListener("click", () => copyText(payload.zipUrl, zipCopy, "Copied")); + zip.append(zipBody, zipCopy); + resultList.append(zip); + } + + async function copyText(text, button, copiedLabel) { + if (!text) { + return; + } + await navigator.clipboard.writeText(text); + const previous = button.textContent; + button.textContent = copiedLabel; + setTimeout(() => { + button.textContent = previous; + }, 1400); + } + + function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); } })(); diff --git a/backend/templates/layouts/base.gohtml b/backend/templates/layouts/base.gohtml index e2caff6..a3f2a7e 100644 --- a/backend/templates/layouts/base.gohtml +++ b/backend/templates/layouts/base.gohtml @@ -6,7 +6,7 @@