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:
2026-05-25 16:26:47 +03:00
parent 9b8ef16474
commit e12878887c
21 changed files with 1240 additions and 153 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ bin/
*.out
# Local runtime data
data/
backend/static/uploads/*
!backend/static/uploads/.gitkeep

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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

14
backend/go.sum Normal file
View File

@@ -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=

View File

@@ -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),

View File

@@ -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())
}

View 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())
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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;
}
}

View File

@@ -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",
});
}
})();

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{.AppName}}</title>
<meta name="description" content="{{.Description}}">
<meta name="theme-color" content="#ffffff">
<meta name="theme-color" content="#09090b">
<meta property="og:site_name" content="{{.AppName}}">
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
<meta property="og:description" content="{{.Description}}">
@@ -16,7 +16,7 @@
<link rel="stylesheet" href="/static/css/app.css">
<script defer src="/static/js/app.js"></script>
</head>
<body>
<body class="dark">
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<nav class="nav" aria-label="Main navigation">
@@ -36,7 +36,8 @@
</main>
<footer class="site-footer">
<span>&copy; {{.CurrentYear}} {{.AppName}}</span>
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span class="footer-links"><a href="/">Upload</a><a href="/healthz">Health</a></span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,41 @@
{{define "download.gohtml"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="download-title">
<div class="card download-card">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
</div>
<h1 id="download-title">Download files</h1>
<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>
{{if .Data.Files}}
<div class="badge-row">
<span class="badge">Expires {{.Data.ExpiresLabel}}</span>
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
</div>
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download zip
</a>
<div class="download-list">
{{range .Data.Files}}
<a class="download-item" href="{{.URL}}">
<span>
<strong>{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</span>
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
</a>
{{end}}
</div>
{{else}}
<p class="download-subtitle">{{.Data.ExpiresLabel}}</p>
{{end}}
</div>
</div>
</section>
{{end}}

View File

@@ -2,45 +2,80 @@
{{define "content"}}
<section class="upload-view" aria-labelledby="upload-title">
<form class="upload-panel" action="/api/v1/upload" method="post" enctype="multipart/form-data">
<div class="panel-header">
<h1 id="upload-title">Upload your files</h1>
<p>Drag files here or choose them from your device.</p>
</div>
<div class="hero-copy">
<h1 id="upload-title">Send a file. Get a link.</h1>
<p>Anonymous, self-hosted transfers. No account required.</p>
</div>
<label class="drop-zone" for="file-input">
<span class="drop-icon" aria-hidden="true">+</span>
<span class="drop-title">Drop files to upload</span>
<span class="drop-copy">Click to browse</span>
<input id="file-input" name="file" type="file" multiple>
</label>
<form class="upload-panel card" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">
<div class="card-content">
<label class="drop-zone" for="file-input">
<span class="drop-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
</span>
<span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · Links expire in 7 days</span>
<input id="file-input" name="file" type="file" multiple>
</label>
<details class="advanced-options">
<summary>Advanced options</summary>
<div class="option-grid">
<label>
<span>Expires after</span>
<select name="max_days">
<option value="7">7 days</option>
<option value="1">1 day</option>
<option value="30">30 days</option>
</select>
</label>
<label>
<span>Max downloads</span>
<input type="number" name="max_downloads" min="1" placeholder="Unlimited">
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" placeholder="Optional">
</label>
<details class="advanced-options">
<summary>
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
Advanced options
</summary>
<div class="option-grid">
<label>
<span>Expires in</span>
<select name="max_days">
<option value="7">7 days</option>
<option value="1">1 day</option>
<option value="30">30 days</option>
</select>
</label>
<label>
<span>Max downloads</span>
<input type="number" name="max_downloads" min="1" placeholder="Unlimited">
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" placeholder="Coming soon" disabled>
</label>
</div>
</details>
<div class="upload-progress" id="upload-progress" hidden>
<div class="progress-row">
<span>Uploading</span>
<span id="upload-status">Preparing...</span>
</div>
<div class="progress"><span></span></div>
</div>
</details>
<div class="form-footer">
<p>Current max file size: {{.Data.MaxUploadSize}}</p>
<button class="button button-primary" type="submit">Upload files</button>
<div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-primary" type="submit">Upload files</button>
</div>
</div>
</form>
<section class="upload-result card" id="upload-result" hidden aria-live="polite">
<div class="card-content">
<div class="result-header">
<div>
<div class="result-title">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
<span>Upload complete</span>
</div>
<p id="result-meta"></p>
</div>
<div class="result-actions">
<button class="button button-outline" type="button" id="copy-all">Copy all</button>
<a class="button button-primary" id="open-box" href="/">Open box</a>
</div>
</div>
<div class="result-list" id="result-list"></div>
</div>
</section>
</section>
{{end}}

View File

@@ -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

View File

@@ -14,5 +14,9 @@ set -a
source "${ENV_FILE}"
set +a
if [[ "${WARPBOX_DATA_DIR:-}" != /* ]]; then
export WARPBOX_DATA_DIR="${ROOT_DIR}/${WARPBOX_DATA_DIR:-data}"
fi
cd "${ROOT_DIR}/backend"
exec go run ./cmd/warpbox