Files
warpbox-dev/backend/libs/handlers/download.go

293 lines
8.4 KiB
Go
Raw Normal View History

package handlers
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type downloadPageData struct {
Box boxView
Files []fileView
ZipURL string
Locked bool
Obfuscated bool
CanPreview bool
DownloadCount int
MaxDownloads int
ExpiresLabel string
}
type boxView struct {
ID string
}
type fileView struct {
ID string
Name string
Size string
ContentType string
PreviewKind string
URL string
DownloadURL string
ThumbnailURL string
}
type previewPageData struct {
Box boxView
File fileView
Locked bool
DownloadURL string
}
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
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.html", web.PageData{
Title: "Download unavailable",
Description: "This Warpbox link is no longer available.",
Data: downloadPageData{
Box: boxView{ID: box.ID},
ExpiresLabel: err.Error(),
},
})
return
}
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
files := make([]fileView, 0, len(box.Files))
if !(locked && box.Obfuscate) {
for _, file := range box.Files {
files = append(files, a.fileView(box, file))
}
}
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
Title: "Download files",
Description: "Download files shared through Warpbox.",
Data: downloadPageData{
Box: boxView{ID: box.ID},
Files: files,
ZipURL: fmt.Sprintf("/d/%s/zip", box.ID),
Locked: locked,
Obfuscated: box.Obfuscate,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
},
})
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
view := a.fileView(box, file)
title := file.Name
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
imageURL := absoluteURL(r, view.ThumbnailURL)
if locked && box.Obfuscate {
title = "Protected Warpbox file"
description = "This shared file is password protected."
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
}
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
Title: title,
Description: description,
ImageURL: imageURL,
Data: previewPageData{
Box: boxView{ID: box.ID},
File: view,
Locked: locked,
DownloadURL: view.DownloadURL,
},
})
}
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
}
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return
}
path := a.uploadService.ThumbnailPath(box, file)
if path == "" {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return
}
http.ServeFile(w, r, path)
}
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return
}
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: unlockCookieName(box.ID),
Value: a.uploadService.UnlockToken(box),
Path: "/d/" + box.ID,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: box.ExpiresAt,
})
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
}
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return services.Box{}, services.File{}, false
}
if err := a.uploadService.CanDownload(box); err != nil {
http.Error(w, err.Error(), statusForDownloadError(err))
return services.Box{}, services.File{}, false
}
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
if err != nil {
http.NotFound(w, r)
return services.Box{}, services.File{}, false
}
return box, file, true
}
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
path := a.uploadService.FilePath(box, file)
source, err := os.Open(path)
if err != nil {
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)
if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
}
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "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
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
return
}
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
}
}
func (a *App) fileView(box services.Box, file services.File) fileView {
return fileView{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
ContentType: file.ContentType,
PreviewKind: file.PreviewKind,
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
}
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) {
return true
}
cookie, err := r.Cookie(unlockCookieName(box.ID))
if err != nil {
return false
}
return cookie.Value == a.uploadService.UnlockToken(box)
}
func unlockCookieName(boxID string) string {
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
}
func absoluteURL(r *http.Request, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
}