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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
105
backend/libs/logging/logger.go
Normal file
105
backend/libs/logging/logger.go
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user