Compare commits

...
4 Commits
Author SHA1 Message Date
kato 6a7590493c refactor(upload): use IncomingFile interface instead of multipart headers
Build and Publish Docker Image / deploy (push) Successful in 1m58s
Refactors the upload handler to use the `services.IncomingFile` interface instead of concrete `*multipart.FileHeader` pointers. This decouples the core upload logic from the HTTP multipart implementation, allowing for more flexible file sources.

Changes include:
- Introducing `namedMultipartFile` to adapt multipart headers to the new interface.
- Updating `createOrAppendBox`, `checkUploadPolicy`, and `totalUploadBytes` to accept `IncomingFile`.
- Renaming service calls to `CreateBoxFromIncoming` and `AppendIncomingFiles`.
2026-06-10 18:19:45 +03:00
kato 5d77b36634 feat: support folder uploads and sanitize upload paths
- Implement `cleanUploadDisplayName` in the backend to safely sanitize uploaded file paths, preserving directory structures while stripping unsafe characters and preventing path traversal.
- Add folder upload capability in the frontend using the Directory Picker API.
- Implement desktop notifications for completed uploads.
2026-06-10 18:14:29 +03:00
kato 0b8d4a3ab9 chore(copy): standardize metadata formatting and descriptions
- Replace middle dots (·) and em-dashes (—) with pipes (|) and standard punctuation in page titles, descriptions, and image alt texts.
- Shorten the homepage description to be more concise and direct.
- Update file share description phrasing for better readability, changing "click to preview" to "Open to preview" and capitalizing "Expires".
2026-06-10 12:56:22 +03:00
kato 0b4487ac2e feat(upload): warn on large uploads over slow/metered connections
Build and Publish Docker Image / deploy (push) Successful in 1m54s
Detects if the user is on a slow (2G/3G) or metered (saveData) connection
and prompts them with a confirmation dialog if they attempt to upload
files totaling 200MB or more.

This prevents accidental high data usage and warns users about potential
long upload times. Also includes the dialogs JS and CSS in the base
layout to support the confirmation modal.
2026-06-08 13:34:05 +03:00
15 changed files with 992 additions and 38 deletions
+1
View File
@@ -17,3 +17,4 @@ scripts/env/dev.env
docker-compose.yml
.claude
docs/possible_new_features
+2 -2
View File
@@ -141,7 +141,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
description := fmt.Sprintf("%d file%s shared via Warpbox | Expires %s.", len(box.Files), plural(len(box.Files)), expiresLabel)
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
imageType := "image/jpeg"
@@ -203,7 +203,7 @@ func fileShareDescription(size, contentType string, expiresAt time.Time) string
if strings.TrimSpace(contentType) == "" {
contentType = "file"
}
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
return fmt.Sprintf("%s %s. Open to preview or download. Expires %s.", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
}
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
+2 -2
View File
@@ -62,10 +62,10 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files",
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.",
Description: "Upload and share files quickly. Drop a file, get a link.",
CanonicalURL: absoluteURL(r, "/"),
ImageURL: absoluteURL(r, "/static/og-default.png"),
ImageAlt: "Warp Box simple file sharing and fast downloads",
ImageAlt: "Warp Box | simple file sharing and fast downloads",
CurrentUser: currentUser,
Data: homeData{
MaxUploadSize: maxUploadSize,
+49 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
@@ -62,7 +63,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
files := uploadFiles(r)
files := uploadIncomingFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string
var collectionID string
@@ -164,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" {
if enforceBoxLimits {
@@ -172,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
@@ -193,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL
@@ -209,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
@@ -229,13 +230,13 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []services.IncomingFile, totalBytes int64) (int, string) {
if len(files) == 0 {
return 0, ""
}
sizes := make([]int64, 0, len(files))
for _, file := range files {
sizes = append(sizes, file.Size)
sizes = append(sizes, file.Size())
}
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
}
@@ -383,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
return "ip:" + uploadClientIP(r)
}
func totalUploadBytes(files []*multipart.FileHeader) int64 {
func totalUploadBytes(files []services.IncomingFile) int64 {
var total int64
for _, file := range files {
total += file.Size
total += file.Size()
}
return total
}
@@ -409,13 +410,48 @@ func statusForDownloadError(err error) int {
return http.StatusForbidden
}
func uploadFiles(r *http.Request) []*multipart.FileHeader {
type namedMultipartFile struct {
header *multipart.FileHeader
name string
}
func (f namedMultipartFile) Name() string {
if strings.TrimSpace(f.name) != "" {
return f.name
}
return f.header.Filename
}
func (f namedMultipartFile) Size() int64 {
return f.header.Size
}
func (f namedMultipartFile) ContentType() string {
return f.header.Header.Get("Content-Type")
}
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
return f.header.Open()
}
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
if r.MultipartForm == nil {
return nil
}
files := make([]*multipart.FileHeader, 0)
files = append(files, r.MultipartForm.File["file"]...)
files = append(files, r.MultipartForm.File["sharex"]...)
fileHeaders := r.MultipartForm.File["file"]
shareXHeaders := r.MultipartForm.File["sharex"]
paths := r.MultipartForm.Value["file_path"]
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
for index, header := range fileHeaders {
name := ""
if index < len(paths) {
name = paths[index]
}
files = append(files, namedMultipartFile{header: header, name: name})
}
for _, header := range shareXHeaders {
files = append(files, namedMultipartFile{header: header})
}
return files
}
+3 -1
View File
@@ -17,6 +17,7 @@ import (
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
@@ -127,7 +128,7 @@ func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("download page did not render text thumbnail image: %s", body)
}
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
if !strings.Contains(body, "Open to preview or download") {
t.Fatalf("social preview body missing preview/download description: %s", body)
}
}
@@ -817,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
t.Fatalf("NewBanService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
+9
View File
@@ -22,6 +22,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/image/font"
@@ -38,8 +39,12 @@ type ThumbnailJobResult struct {
Failed int
}
var thumbnailJobs sync.WaitGroup
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
thumbnailJobs.Add(1)
go func() {
defer thumbnailJobs.Done()
box, err := uploadService.GetBox(boxID)
if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
@@ -61,6 +66,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger
}()
}
func WaitForThumbnailJobs() {
thumbnailJobs.Wait()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{
name: "thumbnail",
+3 -3
View File
@@ -319,7 +319,7 @@ func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (Uplo
}
box.Files = append(box.Files, File{
ID: fileID,
Name: filepath.Base(incoming.Name),
Name: cleanUploadDisplayName(incoming.Name),
StoredName: storedName,
Size: incoming.Size,
ContentType: contentType,
@@ -557,7 +557,7 @@ func (s *UploadService) saveResumableSession(session ResumableSession) error {
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
sessionFiles := make([]ResumableFile, 0, len(files))
for _, file := range files {
file.Name = filepath.Base(strings.TrimSpace(file.Name))
file.Name = cleanUploadDisplayName(file.Name)
if file.Name == "." || file.Name == "" {
return nil, fmt.Errorf("file name is required")
}
@@ -594,7 +594,7 @@ func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts
}
func resumableFileKey(name string, size int64, fingerprint string) string {
return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size)
return strings.TrimSpace(fingerprint) + "|" + cleanUploadDisplayName(name) + "|" + fmt.Sprintf("%d", size)
}
type resumableIncomingFile struct {
+32 -1
View File
@@ -16,6 +16,7 @@ import (
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -452,7 +453,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
box.Files = append(box.Files, File{
ID: fileID,
Name: filepath.Base(incoming.Name()),
Name: cleanUploadDisplayName(incoming.Name()),
StoredName: storedName,
Size: incoming.Size(),
ContentType: contentType,
@@ -464,6 +465,36 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
return nil
}
func cleanUploadDisplayName(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = strings.TrimLeft(clean, "/")
clean = path.Clean(clean)
if clean == "." || clean == "/" || clean == "" {
return "download"
}
parts := strings.Split(clean, "/")
safeParts := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
continue
}
part = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || r == '/' || r == '\\' {
return -1
}
return r
}, part)
if part != "" {
safeParts = append(safeParts, part)
}
}
if len(safeParts) == 0 {
return "download"
}
return strings.Join(safeParts, "/")
}
func (s *UploadService) GetBox(id string) (Box, error) {
var box Box
err := s.db.View(func(tx *bbolt.Tx) error {
+263
View File
@@ -0,0 +1,263 @@
.warpbox-dialog-overlay {
position: fixed;
inset: 0;
z-index: 130;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 60%, transparent);
backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 160ms ease;
}
.warpbox-dialog-overlay.is-visible {
opacity: 1;
}
.warpbox-dialog {
position: relative;
width: min(28rem, 100%);
max-height: min(34rem, 90vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--card-foreground);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(0.6rem) scale(0.98);
transition: opacity 160ms ease, transform 160ms ease;
}
.warpbox-dialog:focus {
outline: none;
}
.warpbox-dialog-overlay.is-visible .warpbox-dialog {
opacity: 1;
transform: translateY(0) scale(1);
}
.warpbox-dialog-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
padding: 1.1rem 3.25rem 0 1.1rem;
}
.warpbox-dialog-icon {
width: 1.9rem;
height: 1.9rem;
display: grid;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 800;
line-height: 1;
}
.warpbox-dialog-warning .warpbox-dialog-icon {
background: color-mix(in srgb, var(--primary) 26%, transparent);
color: var(--primary-hover);
}
.warpbox-dialog-error .warpbox-dialog-icon {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.warpbox-dialog-title {
margin: 0;
font-size: 1.1rem;
line-height: 1.3;
}
.warpbox-dialog-close {
position: absolute;
top: 1.1rem;
right: 1.1rem;
z-index: 2;
min-height: 1.9rem;
height: 1.9rem;
width: 1.9rem;
padding: 0;
border-color: var(--border);
color: var(--muted-foreground);
background: var(--surface-1);
font-size: 1rem;
line-height: 1;
}
.warpbox-dialog-close:hover {
color: var(--foreground);
background: var(--surface-1-hover);
}
.warpbox-dialog-body {
padding: 0.85rem 1.1rem 1.1rem;
overflow: auto;
}
.warpbox-dialog-message {
margin: 0 0 0.75rem;
color: var(--muted-foreground);
font-size: 0.92rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.warpbox-dialog-message:last-child {
margin-bottom: 0;
}
.warpbox-dialog-field {
width: 100%;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 0.35rem);
background: var(--surface-1);
color: var(--foreground);
padding: 0.55rem 0.7rem;
font: inherit;
}
.warpbox-dialog-field:focus {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.warpbox-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding: 0 1.1rem 1.1rem;
}
html.warpbox-dialog-open,
html.warpbox-dialog-open body {
overflow: hidden;
touch-action: none;
}
.dialog-file-list {
display: grid;
gap: 0.5rem;
margin-top: 0.25rem;
max-height: 14rem;
overflow: auto;
padding-right: 0.25rem;
}
.dialog-file-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.35rem);
background: var(--surface-1);
}
.dialog-file-icon {
width: 1.35rem;
height: 1.35rem;
color: var(--muted-foreground);
}
.dialog-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.86rem;
}
.dialog-file-size {
color: var(--muted-foreground);
font-size: 0.8rem;
white-space: nowrap;
}
:root[data-theme="retro"] .warpbox-dialog {
border: 1px solid #000000;
border-radius: 0;
background: #c0c0c0;
color: #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, 0.45);
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
}
:root[data-theme="retro"] .warpbox-dialog-head {
padding-top: 0.2rem;
}
:root[data-theme="retro"] .warpbox-dialog::before {
content: "Warpbox";
display: block;
margin: 0.18rem 0.18rem 0;
padding: 0.22rem 0.35rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 0.78rem;
font-weight: 700;
}
:root[data-theme="retro"] .warpbox-dialog-error::before {
content: "Warpbox - Error";
}
:root[data-theme="retro"] .warpbox-dialog-warning::before {
content: "Warpbox - Warning";
}
:root[data-theme="retro"] .warpbox-dialog-info::before {
content: "Warpbox - Info";
}
:root[data-theme="retro"] .warpbox-dialog-icon {
border: 1px solid #000000;
background: #ffffff;
color: #000078;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .warpbox-dialog-warning .warpbox-dialog-icon {
color: #9a5b00;
}
:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon {
color: #c00000;
}
:root[data-theme="retro"] .warpbox-dialog-message {
color: #000000;
}
:root[data-theme="retro"] .warpbox-dialog-close {
top: 0.36rem;
right: 0.3rem;
width: 1.1rem;
height: 0.95rem;
min-height: 0.95rem;
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
font-size: 0.6rem;
font-weight: 700;
}
@media (max-width: 640px) {
.warpbox-dialog-overlay {
padding: 0.75rem;
}
.warpbox-dialog {
width: 100%;
}
}
+299
View File
@@ -0,0 +1,299 @@
(function () {
const VARIANTS = ["info", "warning", "error"];
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
window.Warpbox = window.Warpbox || {};
let dialogIdCounter = 0;
function defaultTitle(variant) {
if (variant === "error") {
return "Error";
}
if (variant === "warning") {
return "Warning";
}
return "Info";
}
function normalizeOptions(options, message) {
if (typeof options === "string") {
options = { message: options };
} else {
options = options || {};
}
if (message) {
options.message = message;
}
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
return {
variant,
title: options.title || defaultTitle(variant),
message: options.message || "",
body: options.body || null,
actions: Array.isArray(options.actions) ? options.actions : [],
dismissible: options.dismissible !== false,
closable: options.closable !== false,
onClose: typeof options.onClose === "function" ? options.onClose : null,
};
}
function focusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
}
function dialog(options, message) {
const config = normalizeOptions(options, message);
const previouslyFocused = document.activeElement;
dialogIdCounter += 1;
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
const overlay = document.createElement("div");
overlay.className = "warpbox-dialog-overlay";
const card = document.createElement("div");
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", titleId);
card.setAttribute("tabindex", "-1");
const head = document.createElement("div");
head.className = "warpbox-dialog-head";
const icon = document.createElement("span");
icon.className = "warpbox-dialog-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
const title = document.createElement("h2");
title.id = titleId;
title.className = "warpbox-dialog-title";
title.textContent = config.title;
head.append(icon, title);
if (config.closable) {
const close = document.createElement("button");
close.type = "button";
close.className = "warpbox-dialog-close";
close.setAttribute("aria-label", "Close dialog");
close.textContent = "x";
close.addEventListener("click", () => closeDialog());
head.append(close);
}
const body = document.createElement("div");
body.className = "warpbox-dialog-body";
if (config.message) {
const text = document.createElement("p");
text.className = "warpbox-dialog-message";
text.textContent = config.message;
body.append(text);
}
if (config.body) {
const nodes = Array.isArray(config.body) ? config.body : [config.body];
nodes.forEach((node) => {
if (node instanceof Node) {
body.append(node);
}
});
}
card.append(head, body);
let autofocusTarget = null;
if (config.actions.length > 0) {
const actions = document.createElement("div");
actions.className = "warpbox-dialog-actions";
config.actions.forEach((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
button.textContent = action.label || "OK";
button.addEventListener("click", () => {
if (typeof action.onClick === "function") {
action.onClick();
}
if (action.dismiss !== false) {
closeDialog();
}
});
if (action.autofocus) {
autofocusTarget = button;
}
actions.append(button);
});
card.append(actions);
}
overlay.append(card);
document.body.append(overlay);
document.documentElement.classList.add("warpbox-dialog-open");
window.requestAnimationFrame(() => {
overlay.classList.add("is-visible");
(autofocusTarget || card).focus();
});
function handleKeydown(event) {
if (event.key === "Escape") {
if (config.dismissible) {
event.preventDefault();
closeDialog();
}
return;
}
if (event.key !== "Tab") {
return;
}
const focusable = focusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function handleOverlayClick(event) {
if (config.dismissible && event.target === overlay) {
closeDialog();
}
}
document.addEventListener("keydown", handleKeydown, true);
overlay.addEventListener("click", handleOverlayClick);
let closed = false;
function closeDialog() {
if (closed) {
return;
}
closed = true;
document.removeEventListener("keydown", handleKeydown, true);
overlay.removeEventListener("click", handleOverlayClick);
overlay.classList.remove("is-visible");
document.documentElement.classList.remove("warpbox-dialog-open");
window.setTimeout(() => overlay.remove(), 180);
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
previouslyFocused.focus();
}
if (config.onClose) {
config.onClose();
}
}
return {
element: overlay,
close: closeDialog,
};
}
window.Warpbox.dialog = dialog;
window.Warpbox.alertDialog = function alertDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
resolve();
},
});
});
};
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(false);
},
});
});
};
window.Warpbox.promptDialog = function promptDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
const field = document.createElement("input");
field.type = config.inputType || "text";
field.className = "warpbox-dialog-field";
if (config.placeholder) {
field.placeholder = config.placeholder;
}
if (typeof config.value === "string") {
field.value = config.value;
}
let controller = null;
field.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
settle(field.value);
if (controller) {
controller.close();
}
}
});
controller = dialog({
...config,
message: typeof message === "string" ? message : config.message,
body: field,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(null);
},
});
window.requestAnimationFrame(() => field.focus());
});
};
})();
+276 -13
View File
@@ -14,9 +14,11 @@
const openBox = document.querySelector("#open-box");
const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload");
const folderPicker = document.querySelector("[data-folder-picker]");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024;
if (!form || !dropZone || !fileInput) {
return;
@@ -74,18 +76,18 @@
});
document.addEventListener("drop", (event) => {
if (!event.dataTransfer || !event.dataTransfer.files.length) {
if (!hasTransferFiles(event.dataTransfer)) {
return;
}
event.preventDefault();
if (!dropZone.contains(event.target)) {
addSelectedFiles(event.dataTransfer.files);
addDroppedFiles(event.dataTransfer);
}
});
dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
addSelectedFiles(event.dataTransfer.files);
if (hasTransferFiles(event.dataTransfer)) {
addDroppedFiles(event.dataTransfer);
}
});
@@ -94,6 +96,36 @@
fileInput.value = "";
});
document.addEventListener("paste", (event) => {
if (!event.clipboardData || !event.clipboardData.files || event.clipboardData.files.length === 0) {
return;
}
if (isTextEditingTarget(event.target)) {
return;
}
event.preventDefault();
addSelectedFiles(event.clipboardData.files, { source: "pasted" });
});
if (folderPicker && typeof window.showDirectoryPicker === "function") {
folderPicker.hidden = false;
folderPicker.addEventListener("click", async () => {
if (uploadLocked) {
return;
}
try {
updateStatus("Reading folder...");
const directory = await window.showDirectoryPicker();
const files = await filesFromDirectoryHandle(directory, directory.name || "");
addSelectedFiles(files, { source: "folder" });
} catch (error) {
if (!error || error.name !== "AbortError") {
updateStatus("Folder could not be read.");
}
}
});
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (selectedFiles.length === 0) {
@@ -106,9 +138,16 @@
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
return;
}
if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) {
const proceed = await confirmCellularUpload(selectedFiles);
if (!proceed) {
return;
}
}
const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData();
await maybeRequestUploadNotificationPermission(selectedFiles);
if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles);
} else {
@@ -119,6 +158,7 @@
try {
const payload = await uploadResumable(form.action, formData, selectedFiles);
renderResult(payload);
showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl);
await clearSharedTargetPayload();
form.reset();
selectedFiles = [];
@@ -137,6 +177,7 @@
} catch (error) {
updateStatus(error.message || "Upload failed");
notifyUploadError(error);
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
} finally {
setLoading(false, submit);
}
@@ -166,7 +207,7 @@
recoverResumableSessions();
}
function addSelectedFiles(files) {
function addSelectedFiles(files, options) {
if (uploadLocked) {
return;
}
@@ -183,9 +224,132 @@
if (rejected.length > 0) {
notifyRejectedFiles(rejected);
}
if (options && options.source === "pasted" && files && files.length > 0) {
updateStatus(`${files.length} pasted file${files.length === 1 ? "" : "s"} ready.`);
}
if (options && options.source === "folder" && files && files.length > 0) {
updateStatus(`${files.length} folder file${files.length === 1 ? "" : "s"} ready.`);
}
updateSelectedState();
}
async function addDroppedFiles(dataTransfer) {
if (uploadLocked) {
return;
}
const files = await filesFromDataTransfer(dataTransfer);
addSelectedFiles(files, { source: hasDirectoryItems(dataTransfer) ? "folder" : "dropped" });
}
async function filesFromDataTransfer(dataTransfer) {
const items = Array.from(dataTransfer.items || []);
const entries = items
.map((item) => typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null)
.filter(Boolean);
if (entries.length === 0) {
return Array.from(dataTransfer.files || []);
}
const nested = await Promise.all(entries.map((entry) => filesFromEntry(entry, "")));
return nested.flat();
}
function hasDirectoryItems(dataTransfer) {
return Array.from(dataTransfer.items || []).some((item) => {
const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null;
return entry && entry.isDirectory;
});
}
function hasTransferFiles(dataTransfer) {
if (!dataTransfer) {
return false;
}
if (dataTransfer.files && dataTransfer.files.length > 0) {
return true;
}
return Array.from(dataTransfer.items || []).some((item) => item.kind === "file");
}
function filesFromEntry(entry, parentPath) {
if (!entry) {
return Promise.resolve([]);
}
const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
if (entry.isFile) {
return new Promise((resolve) => {
entry.file((file) => resolve([withRelativePath(file, relativePath)]), () => resolve([]));
});
}
if (!entry.isDirectory) {
return Promise.resolve([]);
}
const reader = entry.createReader();
const children = [];
return new Promise((resolve) => {
const readBatch = () => {
reader.readEntries(async (entries) => {
if (!entries.length) {
const nested = await Promise.all(children.map((child) => filesFromEntry(child, relativePath)));
resolve(nested.flat());
return;
}
children.push(...entries);
readBatch();
}, () => resolve([]));
};
readBatch();
});
}
async function filesFromDirectoryHandle(directory, parentPath) {
const files = [];
for await (const [name, handle] of directory.entries()) {
const relativePath = parentPath ? `${parentPath}/${name}` : name;
if (handle.kind === "file") {
const file = await handle.getFile();
files.push(withRelativePath(file, relativePath));
} else if (handle.kind === "directory") {
files.push(...await filesFromDirectoryHandle(handle, relativePath));
}
}
return files;
}
function withRelativePath(file, relativePath) {
if (!file || !relativePath) {
return file;
}
try {
Object.defineProperty(file, "warpboxRelativePath", {
value: normalizeRelativePath(relativePath),
configurable: true,
});
} catch (error) {
file.warpboxRelativePath = normalizeRelativePath(relativePath);
}
return file;
}
function normalizeRelativePath(value) {
return String(value || "")
.replace(/\\/g, "/")
.split("/")
.filter((part) => part && part !== "." && part !== "..")
.join("/");
}
function uploadName(file) {
return normalizeRelativePath(file && (file.warpboxRelativePath || file.webkitRelativePath || file.name)) || (file && file.name) || "file";
}
function isTextEditingTarget(target) {
if (!target) {
return false;
}
const tag = (target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || target.isContentEditable;
}
function fileExceedsUploadLimit(file) {
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
}
@@ -222,12 +386,109 @@
});
}
async function maybeRequestUploadNotificationPermission(files) {
if (!("Notification" in window) || Notification.permission !== "default" || totalSelectedBytes(files) < CELLULAR_WARNING_THRESHOLD_BYTES) {
return;
}
try {
await Notification.requestPermission();
} catch (error) {
/* notification permission is optional */
}
}
async function showUploadNotification(title, body, url) {
if (!("Notification" in window) || Notification.permission !== "granted") {
return;
}
if (document.visibilityState === "visible") {
return;
}
const options = {
body,
icon: "/static/android-chrome-192x192.png",
badge: "/static/favicon-32x32.png",
data: { url: window.Warpbox.absoluteURL(url || "/") },
};
try {
const registration = navigator.serviceWorker ? await navigator.serviceWorker.ready : null;
if (registration && registration.showNotification) {
await registration.showNotification(title, options);
return;
}
} catch (error) {
/* fall through to page notification */
}
try {
const notification = new Notification(title, options);
notification.onclick = () => {
window.focus();
if (url) {
window.location.href = window.Warpbox.absoluteURL(url);
}
notification.close();
};
} catch (error) {
/* notifications are best-effort */
}
}
function notify(variant, message, options) {
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
window.Warpbox.notify({ ...(options || {}), variant, message });
}
}
function isSlowOrMeteredConnection() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!connection) {
return false;
}
if (connection.saveData === true) {
return true;
}
return ["slow-2g", "2g", "3g"].includes(connection.effectiveType);
}
function totalSelectedBytes(files) {
return files.reduce((sum, file) => sum + file.size, 0);
}
function confirmCellularUpload(files) {
const list = document.createElement("div");
list.className = "dialog-file-list";
files.forEach((file) => {
const icon = document.createElement("span");
icon.className = "svg-icon svg-icon-document dialog-file-icon";
icon.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "dialog-file-name";
name.textContent = file.name;
name.title = file.name;
const size = document.createElement("span");
size.className = "dialog-file-size";
size.textContent = window.Warpbox.formatBytes(file.size);
const row = document.createElement("div");
row.className = "dialog-file-row";
row.append(icon, name, size);
list.append(row);
});
const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files));
const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`;
return window.Warpbox.confirmDialog(message, {
title: "Slow connection detected",
variant: "warning",
body: list,
confirmLabel: "Upload anyway",
cancelLabel: "Cancel",
});
}
function isShareTargetLaunch() {
const params = new URLSearchParams(window.location.search || "");
return params.has("share-target");
@@ -498,7 +759,7 @@
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
const createPayload = {
files: files.map((file, index) => ({
name: file.name,
name: uploadName(file),
size: file.size,
contentType: file.type || "application/octet-stream",
fingerprint: fingerprints[index],
@@ -1025,7 +1286,7 @@
const rows = [];
const localByNameSize = new Map();
(localFiles || []).forEach((file, index) => {
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index });
});
const usedLocalIndexes = new Set();
(session.files || []).forEach((file) => {
@@ -1036,7 +1297,7 @@
usedLocalIndexes.add(localMatch.index);
}
rows.push({
name: file.name,
name: uploadName(file),
size: file.size,
uploadedBytes,
meta: complete
@@ -1056,7 +1317,7 @@
return;
}
rows.push({
name: file.name,
name: uploadName(file),
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
progress: 0,
status: "queued",
@@ -1085,7 +1346,7 @@
uploadQueue.replaceChildren();
files.forEach((file, index) => {
uploadQueue.append(createFileRow({
name: file.name,
name: uploadName(file),
meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
progress: status === "queued" ? 0 : 100,
status,
@@ -1153,14 +1414,16 @@
function uploadFormData() {
const formData = new FormData(form);
formData.delete("file");
formData.delete("file_path");
selectedFiles.forEach((file) => {
formData.append("file", file, file.name);
formData.append("file", file, uploadName(file));
formData.append("file_path", uploadName(file));
});
return formData;
}
function fileIdentity(file) {
return [file.name, file.size, file.lastModified || 0].join(":");
return [uploadName(file), file.size, file.lastModified || 0].join(":");
}
async function fileFingerprint(file) {
@@ -1169,7 +1432,7 @@
}
const sampleSize = Math.min(file.size, 1024 * 1024);
const sample = await file.slice(0, sampleSize).arrayBuffer();
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":"));
const metadata = new TextEncoder().encode([uploadName(file), file.size, file.lastModified || 0, sampleSize].join(":"));
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
combined.set(metadata, 0);
combined.set(new Uint8Array(sample), metadata.byteLength);
+27
View File
@@ -66,6 +66,7 @@
bindLargeGate();
bindThemeChanges();
bindRenderFullscreen();
configureMediaSession();
renderTabs();
selectMode(state.defaultMode);
@@ -301,6 +302,32 @@
document.addEventListener("fullscreenchange", updateRenderFullscreenButton);
}
function configureMediaSession() {
if (!("mediaSession" in navigator) || typeof window.MediaMetadata !== "function") {
return;
}
if (!fileType.isAudio && !fileType.isVideo) {
return;
}
var artworkURL = "";
if (fileType.isVideo && els.videoPane) {
artworkURL = els.videoPane.getAttribute("poster") || state.iconURL || "";
} else {
artworkURL = state.iconURL || "";
}
var metadata = {
title: state.fileName || "Warpbox media",
artist: "Warpbox",
album: state.sizeLabel || state.contentType || ""
};
if (artworkURL) {
metadata.artwork = [
{ src: window.Warpbox.absoluteURL(artworkURL), sizes: "512x512", type: "image/png" }
];
}
navigator.mediaSession.metadata = new MediaMetadata(metadata);
}
function ensureTextLoaded() {
if (state.textLoaded) {
return Promise.resolve(state.textSource);
+20
View File
@@ -5,6 +5,26 @@ self.addEventListener("fetch", (event) => {
}
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
event.waitUntil((async () => {
const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
if ("focus" in client) {
await client.focus();
if ("navigate" in client) {
await client.navigate(url);
}
return;
}
}
if (clients.openWindow) {
await clients.openWindow(url);
}
})());
});
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_PREFIX = "/__warpbox_share_target__/";
const LATEST_KEY = SHARE_PREFIX + "latest";
+3 -1
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} {{end}}{{.AppName}}</title>
<title>{{if .Title}}{{.Title}} | {{end}}{{.AppName}}</title>
<meta name="description" content="{{.Description}}">
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
@@ -54,6 +54,7 @@
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/04-dialogs.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
@@ -70,6 +71,7 @@
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/02-pwa.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/03-popups.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/04-dialogs.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/13-share.js?version={{.AppVersion}}"></script>
+2 -1
View File
@@ -25,7 +25,7 @@
<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-copy">or click to browse, paste files, or drop a folder</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
<input id="file-input" name="file" type="file" multiple>
</label>
@@ -77,6 +77,7 @@
<div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button>
<button class="button button-primary" type="submit">Upload files</button>
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
</div>