6 Commits

Author SHA1 Message Date
d3b6a86753 feat(file-browser): make entire file card clickable in list view
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m45s
Improve the user experience in the file browser list view by allowing users to click anywhere on a file card to open it, rather than just the specific link.

- Add a click event listener to the file browser to handle navigation when clicking a card in list view.
- Ensure interactive elements (like buttons or inputs) inside the card do not trigger the card-wide click.
- Add `cursor: pointer` to list view file cards to indicate they are clickable.
- Update retro theme CSS to style the entire card on hover and focus.
2026-06-02 14:45:55 +03:00
cf5d8bb50d feat(ui): limit visible reactions and overhaul retro theme
- Limit the number of initially visible reactions per file to 2 and calculate the overflow count on the backend.
- Redesign the retro theme CSS to mimic a classic Windows 98 Explorer window, including title bars, toolbars, and sunken panes.
- Add local storage persistence for the file browser view preference (list vs. thumbnails).
2026-06-02 14:43:16 +03:00
8e3f783780 feat(handlers): add file icons with standard and retro variants
Introduce file icon support to the file browser. Icons are loaded on
startup and mapped based on file name and content type.

- Load file icon mappings in the App handler initialization.
- Add `HasThumbnail`, `IconURL`, and `IconRetroURL` to the file view.
- Update CSS to support displaying file icons alongside thumbnails.
- Add retro theme support to swap standard icons with pixelated retro
  variants when the retro theme is active.
2026-06-02 13:02:51 +03:00
6c87187c6d refactor(api): consolidate health check endpoints to /health
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Removes the redundant `/healthz` and `/api/v1/health` endpoints, leaving `/health` as the sole health check endpoint.

- Update router to return 404 Not Found for the removed endpoints
- Update admin log filtering to only ignore `/health`
- Remove health URL from API documentation data
- Update tests to verify `/health` returns 200 and others return 404
- Update README documentation to reflect the change
2026-06-02 11:54:38 +03:00
f628b489af feat: add emoji reaction support for files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
- Implement `ReactionService` to manage file reactions in the database.
- Add `POST /d/{boxID}/f/{fileID}/react` endpoint to handle user reactions.
- Add `GET /emoji/{pack}/{file}` endpoint to serve custom emoji assets.
- Support loading custom emoji packs dynamically from the data directory.
- Update README with instructions on configuring emoji reaction packs.
2026-06-02 11:30:33 +03:00
1ab5021667 feat(config): support large uploads with read header timeout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Disable default read and write timeouts (set to 0s) to prevent Go from
prematurely closing connections during large multi-GB uploads.

Introduce `WARPBOX_READ_HEADER_TIMEOUT` (defaulting to 15s) to protect
against slowloris-style attacks while still allowing long-running
uploads to complete. Update documentation and example configurations
accordingly.
2026-06-01 15:23:28 +03:00
55 changed files with 2412 additions and 175 deletions

View File

@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60 WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES= WARPBOX_TRUSTED_PROXIES=

View File

@@ -38,6 +38,11 @@ Upload policy defaults are also configured in megabytes and can later be changed
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. 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. The dev script resolves that path from the repository root.
Large uploads are expected to take minutes on normal home/server connections. Keep
`WARPBOX_READ_TIMEOUT=0s` and `WARPBOX_WRITE_TIMEOUT=0s` so Go does not close the connection
mid-upload; `WARPBOX_READ_HEADER_TIMEOUT=15s` still protects header reads from slowloris-style
connections.
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with `WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. `WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
@@ -49,6 +54,37 @@ links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
at `/admin/login` if you need to recover access without a user session. at `/admin/login` if you need to recover access without a user session.
## Emoji reaction packs
File reactions use emoji packs from the runtime data directory, not from the application code. By
default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use
`$WARPBOX_DATA_DIR/emoji` instead.
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
directly inside the pack folder:
```text
data/
├── db/
├── files/
├── logs/
└── emoji/
├── openmoji/
│ ├── 1F600.svg
│ ├── 1F44D.svg
│ └── 2764.svg
├── pixel-pack/
│ ├── happy.webp
│ ├── fire.webp
│ └── star.webp
└── custom-work/
├── approved.png
└── shipped.png
```
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`.
Supported emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
For one-off Go commands, run them from the backend module: For one-off Go commands, run them from the backend module:
```bash ```bash
@@ -72,8 +108,7 @@ The compose example also works with Podman compatible compose tools. Its data vo
`./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use `./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use
`/data`, `/app/static`, and `/app/templates`. `/data`, `/app/static`, and `/app/templates`.
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks The image exposes the health endpoint: `/health`. Docker and compose healthchecks use it.
use `/health`.
## Reverse Proxy Security ## Reverse Proxy Security
@@ -106,6 +141,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1 WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
``` ```
Example `/etc/systemd/system/warpbox.service`: Example `/etc/systemd/system/warpbox.service`:

View File

@@ -54,6 +54,24 @@ network edge, or set it to a value that does not include public clients. Direct
public exposure is not recommended; use a reverse proxy for TLS and request public exposure is not recommended; use a reverse proxy for TLS and request
normalization. normalization.
## Large Uploads
Multi-GB uploads must not use whole-body read/write deadlines. Keep these
Warpbox values for production unless you intentionally want a hard wall-clock
upload limit:
```env
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
```
`WARPBOX_READ_HEADER_TIMEOUT` protects request headers. `WARPBOX_READ_TIMEOUT`
and `WARPBOX_WRITE_TIMEOUT` cover the whole upload/response lifetime in Go, so
small values can cause browser errors such as `NS_ERROR_NET_INTERRUPT` during
large transfers. Upload size, daily, storage, and box limits still enforce abuse
controls independently of these timeout values.
## Ban Behavior ## Ban Behavior
Active bans return: Active bans return:

View File

@@ -11,26 +11,27 @@ import (
) )
type Config struct { type Config struct {
AppName string AppName string
AppVersion string AppVersion string
Environment string Environment string
Addr string Addr string
BaseURL string BaseURL string
DataDir string DataDir string
AdminToken string AdminToken string
StaticDir string StaticDir string
TemplateDir string TemplateDir string
ReadTimeout time.Duration ReadHeaderTimeout time.Duration
WriteTimeout time.Duration ReadTimeout time.Duration
IdleTimeout time.Duration WriteTimeout time.Duration
TrustedProxies []string IdleTimeout time.Duration
JobsEnabled bool TrustedProxies []string
CleanupEnabled bool JobsEnabled bool
CleanupEvery time.Duration CleanupEnabled bool
ThumbnailEnabled bool CleanupEvery time.Duration
ThumbnailEvery time.Duration ThumbnailEnabled bool
MaxUploadSize int64 ThumbnailEvery time.Duration
DefaultSettings SettingsDefaults MaxUploadSize int64
DefaultSettings SettingsDefaults
} }
type SettingsDefaults struct { type SettingsDefaults struct {
@@ -55,25 +56,26 @@ type SettingsDefaults struct {
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"), AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"), AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"), Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"), Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"), BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")), DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""), AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")), StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")), TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second), ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second), ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"), IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
DefaultSettings: SettingsDefaults{ DefaultSettings: SettingsDefaults{
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true), AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512), AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),

View File

@@ -1,6 +1,9 @@
package config package config
import "testing" import (
"testing"
"time"
)
func TestParseMegabytes(t *testing.T) { func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{ tests := map[string]int64{
@@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) {
t.Fatalf("envBool() did not fall back to true") t.Fatalf("envBool() did not fall back to true")
} }
} }
func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) {
t.Setenv("WARPBOX_BASE_URL", "http://example.test")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ReadHeaderTimeout != 15*time.Second {
t.Fatalf("ReadHeaderTimeout = %s, want 15s", cfg.ReadHeaderTimeout)
}
if cfg.ReadTimeout != 0 {
t.Fatalf("ReadTimeout = %s, want 0 for long uploads", cfg.ReadTimeout)
}
if cfg.WriteTimeout != 0 {
t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout)
}
}

View File

@@ -1770,7 +1770,7 @@ func isHealthCheckLogEntry(raw map[string]any) bool {
if idx := strings.IndexByte(path, '?'); idx >= 0 { if idx := strings.IndexByte(path, '?'); idx >= 0 {
path = path[:idx] path = path[:idx]
} }
return path == "/health" || path == "/healthz" || path == "/api/v1/health" return path == "/health"
} }
func logEntryFromMap(raw map[string]any) adminLogEntry { func logEntryFromMap(raw map[string]any) adminLogEntry {

View File

@@ -10,7 +10,6 @@ import (
type apiDocsData struct { type apiDocsData struct {
BaseURL string BaseURL string
UploadURL string UploadURL string
HealthURL string
RequestSchemaURL string RequestSchemaURL string
ResponseSchemaURL string ResponseSchemaURL string
ShareXExamplePath string ShareXExamplePath string
@@ -39,7 +38,6 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
Data: apiDocsData{ Data: apiDocsData{
BaseURL: a.cfg.BaseURL, BaseURL: a.cfg.BaseURL,
UploadURL: a.cfg.BaseURL + "/api/v1/upload", UploadURL: a.cfg.BaseURL + "/api/v1/upload",
HealthURL: a.cfg.BaseURL + "/api/v1/health",
RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json", RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json", ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu", ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu",

View File

@@ -16,12 +16,18 @@ type App struct {
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService authService *services.AuthService
settingsService *services.SettingsService settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService banService *services.BanService
rateLimiter *rateLimiter rateLimiter *rateLimiter
uploadGroups *uploadGrouper uploadGroups *uploadGrouper
fileIcons *fileIconSet
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App { func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, reactionService *services.ReactionService, banService *services.BanService) *App {
fileIcons, err := loadFileIcons(cfg.StaticDir)
if err != nil {
logger.Warn("failed to load file icon map", "source", "handlers", "severity", "warn", "error", err.Error())
}
return &App{ return &App{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
@@ -29,9 +35,11 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService, uploadService: uploadService,
authService: authService, authService: authService,
settingsService: settingsService, settingsService: settingsService,
reactionService: reactionService,
banService: banService, banService: banService,
rateLimiter: newRateLimiter(), rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(), uploadGroups: newUploadGrouper(),
fileIcons: fileIcons,
} }
} }
@@ -121,16 +129,22 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /health", a.Health) mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health) mux.HandleFunc("GET /healthz", notFound)
mux.HandleFunc("GET /api/v1/health", a.Health) mux.HandleFunc("GET /api/v1/health", notFound)
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig) mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema) mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema) mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload) mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
mux.Handle("GET /static/", a.Static()) mux.Handle("GET /static/", a.Static())
} }
func notFound(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}

View File

@@ -2,12 +2,15 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -26,6 +29,7 @@ type downloadPageData struct {
DownloadCount int DownloadCount int
MaxDownloads int MaxDownloads int
ExpiresLabel string ExpiresLabel string
EmojiTabs []emojiTabView
} }
type boxView struct { type boxView struct {
@@ -41,6 +45,33 @@ type fileView struct {
URL string URL string
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
HasThumbnail bool
IconURL string
IconRetroURL string
ReactURL string
Reactions []reactionView
ReactionMore int
Reacted bool
}
type reactionView struct {
EmojiID string `json:"emojiId"`
URL string `json:"url"`
Label string `json:"label"`
Count int `json:"count"`
Visible bool `json:"visible"`
}
type emojiTabView struct {
ID string
Label string
Emojis []emojiOptionView
}
type emojiOptionView struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
} }
type previewPageData struct { type previewPageData struct {
@@ -70,13 +101,22 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return return
} }
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
visitorID := a.reactionVisitorID(w, r)
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
if err != nil {
a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...)
}
files := make([]fileView, 0, len(box.Files)) files := make([]fileView, 0, len(box.Files))
if !(locked && box.Obfuscate) { if !(locked && box.Obfuscate) {
for _, file := range box.Files { for _, file := range box.Files {
files = append(files, a.fileView(box, file)) files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID]))
} }
} }
emojiTabs, err := a.emojiTabs()
if err != nil {
a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...)
}
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
@@ -99,6 +139,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount, DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads, MaxDownloads: box.MaxDownloads,
ExpiresLabel: expiresLabel, ExpiresLabel: expiresLabel,
EmojiTabs: emojiTabs,
}, },
}) })
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...) a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
@@ -310,6 +351,12 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) fileView(box services.Box, file services.File) fileView { func (a *App) fileView(box services.Box, file services.File) fileView {
return a.fileViewWithReactions(box, file, nil, false)
}
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
icon := a.fileIcons.lookup(file.Name, file.ContentType)
reactionViews := a.reactionViews(reactions)
return fileView{ return fileView{
ID: file.ID, ID: file.ID,
Name: file.Name, Name: file.Name,
@@ -319,9 +366,183 @@ func (a *App) fileView(box services.Box, file services.File) fileView {
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", 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), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "",
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
Reactions: reactionViews,
ReactionMore: reactionOverflowCount(reactionViews),
Reacted: reacted,
} }
} }
func (a *App) ReactToFile(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
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid reaction", http.StatusBadRequest)
return
}
emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
if !a.validEmojiID(emojiID) {
http.Error(w, "unknown emoji", http.StatusBadRequest)
return
}
visitorID := a.reactionVisitorID(w, r)
reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
if errors.Is(err, os.ErrExist) {
writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
return
}
if err != nil {
a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.Error(w, "could not save reaction", http.StatusInternalServerError)
return
}
a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
writeJSON(w, http.StatusCreated, map[string]any{
"reactions": a.reactionViews(reactions),
"reacted": true,
})
}
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
views := make([]reactionView, 0, len(reactions))
for index, reaction := range reactions {
views = append(views, reactionView{
EmojiID: reaction.EmojiID,
URL: emojiURL(reaction.EmojiID),
Label: emojiLabel(reaction.EmojiID),
Count: reaction.Count,
Visible: index < 2,
})
}
return views
}
func reactionOverflowCount(reactions []reactionView) int {
if len(reactions) <= 2 {
return 0
}
return len(reactions) - 2
}
func (a *App) emojiTabs() ([]emojiTabView, error) {
root := a.emojiRoot()
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
tabs := make([]emojiTabView, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
tabID := entry.Name()
files, err := os.ReadDir(filepath.Join(root, tabID))
if err != nil {
return nil, err
}
tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
for _, file := range files {
if file.IsDir() || !isEmojiFile(file.Name()) {
continue
}
emojiID := tabID + "/" + file.Name()
tab.Emojis = append(tab.Emojis, emojiOptionView{
ID: emojiID,
URL: emojiURL(emojiID),
Label: emojiLabel(emojiID),
})
}
sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
if len(tab.Emojis) > 0 {
tabs = append(tabs, tab)
}
}
sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
return tabs, nil
}
func (a *App) validEmojiID(id string) bool {
id = strings.TrimSpace(id)
if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
return false
}
parts := strings.Split(id, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
return false
}
info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
return err == nil && !info.IsDir()
}
func (a *App) emojiRoot() string {
return filepath.Join(a.cfg.DataDir, "emoji")
}
func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
const cookieName = "warpbox_reactor"
if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
return cookie.Value
}
visitorID := services.RandomPublicToken(32)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: visitorID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().AddDate(1, 0, 0),
})
return visitorID
}
func isEmojiFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}
func emojiTabLabel(id string) string {
label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
if label == "" {
return "Emoji"
}
return strings.ToUpper(label[:1]) + label[1:]
}
func emojiLabel(id string) string {
base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
return strings.ReplaceAll(base, "-", " ")
}
func emojiURL(id string) string {
parts := strings.Split(id, "/")
if len(parts) != 2 {
return ""
}
return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool { func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) { if !a.uploadService.IsProtected(box) {
return true return true

View File

@@ -13,6 +13,10 @@ type healthResponse struct {
} }
func (a *App) Health(w http.ResponseWriter, r *http.Request) { func (a *App) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
http.NotFound(w, r)
return
}
helpers.WriteJSON(w, http.StatusOK, healthResponse{ helpers.WriteJSON(w, http.StatusOK, healthResponse{
Status: "ok", Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339), Time: time.Now().UTC().Format(time.RFC3339),

View File

@@ -13,16 +13,20 @@ func TestHealthRoutes(t *testing.T) {
mux := http.NewServeMux() mux := http.NewServeMux()
app.RegisterRoutes(mux) app.RegisterRoutes(mux)
for _, path := range []string{"/health", "/healthz", "/api/v1/health"} { request := httptest.NewRequest(http.MethodGet, "/health", nil)
t.Run(path, func(t *testing.T) { response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request) mux.ServeHTTP(response, request)
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
}) for _, path := range []string{"/healthz", "/api/v1/health"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", path, response.Code)
}
} }
} }

View File

@@ -0,0 +1,152 @@
package handlers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileIcon holds the two icon filenames for a file type: the standard (modern)
// icon and the retro (Win98) icon. The filenames are resolved against
// static/file-icons/standard and static/file-icons/retro respectively.
type fileIcon struct {
Standard string `json:"standard"`
Retro string `json:"retro"`
}
type iconType struct {
Mime string `json:"mime"`
Standard string `json:"standard"`
Retro string `json:"retro"`
Extensions []string `json:"extensions"`
}
type iconMapFile struct {
Default iconType `json:"default"`
Types []iconType `json:"types"`
}
type mimeRule struct {
pattern string // exact mime ("application/pdf") or major prefix ("image/")
prefix bool
icon fileIcon
}
// fileIconSet is the loaded icon map: an extension lookup plus content-type
// rules and a fallback. It is built once at startup from icon-map.json.
type fileIconSet struct {
byExt map[string]fileIcon
byMime []mimeRule
fallback fileIcon
}
// loadFileIcons reads static/file-icons/icon-map.json and indexes it by
// extension and content type so icons can be assigned at render time.
func loadFileIcons(staticDir string) (*fileIconSet, error) {
data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json"))
if err != nil {
return nil, err
}
var raw iconMapFile
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
set := &fileIconSet{
byExt: make(map[string]fileIcon),
fallback: fileIcon{Standard: raw.Default.Standard, Retro: raw.Default.Retro},
}
if err := validateFileIcon(staticDir, set.fallback); err != nil {
return nil, err
}
for _, t := range raw.Types {
icon := fileIcon{Standard: t.Standard, Retro: t.Retro}
if err := validateFileIcon(staticDir, icon); err != nil {
return nil, err
}
for _, ext := range t.Extensions {
set.byExt[strings.ToLower(strings.TrimPrefix(ext, "."))] = icon
}
if t.Mime == "" {
continue
}
if strings.HasSuffix(t.Mime, "/*") {
set.byMime = append(set.byMime, mimeRule{pattern: strings.TrimSuffix(t.Mime, "*"), prefix: true, icon: icon})
} else {
set.byMime = append(set.byMime, mimeRule{pattern: strings.ToLower(t.Mime), icon: icon})
}
}
return set, nil
}
func validateFileIcon(staticDir string, icon fileIcon) error {
if icon.Standard != "" {
if err := validateFileIconPath(staticDir, "standard", icon.Standard); err != nil {
return err
}
}
if icon.Retro != "" {
if err := validateFileIconPath(staticDir, "retro", icon.Retro); err != nil {
return err
}
}
return nil
}
func validateFileIconPath(staticDir, theme, name string) error {
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return fmt.Errorf("invalid %s file icon path %q", theme, name)
}
path := filepath.Join(staticDir, "file-icons", theme, name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("missing %s file icon %q: %w", theme, name, err)
}
if info.IsDir() {
return fmt.Errorf("%s file icon %q is a directory", theme, name)
}
return nil
}
// lookup resolves a file's icon from its name (extension) first, falling back to
// its content type, then to the default icon. Extension wins because stored
// content types are often the generic application/octet-stream.
func (s *fileIconSet) lookup(name, contentType string) fileIcon {
if s == nil {
return fileIcon{}
}
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")); ext != "" {
if icon, ok := s.byExt[ext]; ok {
return icon
}
}
ct := strings.ToLower(strings.TrimSpace(contentType))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if ct != "" && ct != "application/octet-stream" {
for _, rule := range s.byMime { // exact matches first
if !rule.prefix && rule.pattern == ct {
return rule.icon
}
}
for _, rule := range s.byMime { // then major-type prefixes
if rule.prefix && strings.HasPrefix(ct, rule.pattern) {
return rule.icon
}
}
}
return s.fallback
}
// fileIconURL builds the /static URL for an icon filename in the given theme
// directory ("standard" or "retro").
func fileIconURL(theme, name string) string {
if name == "" {
return ""
}
return "/static/file-icons/" + theme + "/" + name
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"path/filepath"
"testing"
)
func TestFileIconMapLoadsAndResolvesCommonTypes(t *testing.T) {
icons, err := loadFileIcons(filepath.Join("..", "..", "static"))
if err != nil {
t.Fatalf("loadFileIcons returned error: %v", err)
}
tests := []struct {
name string
contentType string
wantStandard string
wantRetro string
}{
{
name: "photo.jpg",
contentType: "application/octet-stream",
wantStandard: "image-document-svgrepo-com.svg",
wantRetro: "shimgvw.dll_14_1-2.png",
},
{
name: "movie.mkv",
contentType: "",
wantStandard: "video-document-svgrepo-com.svg",
wantRetro: "wmploc.dll_14_504-2.png",
},
{
name: "archive.7z",
contentType: "",
wantStandard: "zip-document-svgrepo-com.svg",
wantRetro: "zipfldr.dll_14_101-2.png",
},
{
name: "unknown.bin",
contentType: "application/octet-stream",
wantStandard: "txt-document-svgrepo-com.svg",
wantRetro: "shell32.dll_14_152-2.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := icons.lookup(tt.name, tt.contentType)
if got.Standard != tt.wantStandard || got.Retro != tt.wantRetro {
t.Fatalf("lookup returned %+v, want standard=%q retro=%q", got, tt.wantStandard, tt.wantRetro)
}
})
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@@ -15,6 +16,24 @@ func (a *App) Static() http.Handler {
}) })
} }
func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
pack := strings.TrimSpace(r.PathValue("pack"))
file := strings.TrimSpace(r.PathValue("file"))
if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) {
http.NotFound(w, r)
return
}
path := filepath.Join(a.emojiRoot(), pack, file)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
setStaticCacheHeaders(w, r.URL.Path)
http.ServeFile(w, r, path)
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) { func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))

View File

@@ -46,6 +46,42 @@ func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
} }
} }
func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
if len(payload.Files) != 1 {
t.Fatalf("uploaded files = %d", len(payload.Files))
}
request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.ReactToFile(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"count":1`) {
t.Fatalf("reaction response missing count: %s", response.Body.String())
}
retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
retry.Header.Set("Content-Type", "application/x-www-form-urlencoded")
retry.SetPathValue("boxID", payload.BoxID)
retry.SetPathValue("fileID", payload.Files[0].ID)
for _, cookie := range response.Result().Cookies() {
retry.AddCookie(cookie)
}
retryResponse := httptest.NewRecorder()
app.ReactToFile(retryResponse, retry)
if retryResponse.Code != http.StatusConflict {
t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String())
}
}
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) { func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -198,6 +234,14 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil { if err != nil {
t.Fatalf("NewUploadService returned error: %v", err) t.Fatalf("NewUploadService returned error: %v", err)
} }
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil {
service.Close()
t.Fatalf("create emoji test dir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`), 0o644); err != nil {
service.Close()
t.Fatalf("write emoji test file: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL) renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil { if err != nil {
service.Close() service.Close()
@@ -213,12 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewSettingsService returned error: %v", err) t.Fatalf("NewSettingsService returned error: %v", err)
} }
reactionService, err := services.NewReactionService(service.DB())
if err != nil {
service.Close()
t.Fatalf("NewReactionService returned error: %v", err)
}
banService, err := services.NewBanService(service.DB()) banService, err := services.NewBanService(service.DB())
if err != nil { if err != nil {
service.Close() service.Close()
t.Fatalf("NewBanService returned error: %v", err) t.Fatalf("NewBanService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() { return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -32,13 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
reactionService, err := services.NewReactionService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB()) banService, err := services.NewBanService(uploadService.DB())
if err != nil { if err != nil {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService) stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService) app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService)
router := http.NewServeMux() router := http.NewServeMux()
app.RegisterRoutes(router) app.RegisterRoutes(router)
@@ -54,11 +59,12 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
) )
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: handler, Handler: handler,
ReadTimeout: cfg.ReadTimeout, ReadHeaderTimeout: cfg.ReadHeaderTimeout,
WriteTimeout: cfg.WriteTimeout, ReadTimeout: cfg.ReadTimeout,
IdleTimeout: cfg.IdleTimeout, WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
} }
server.RegisterOnShutdown(func() { server.RegisterOnShutdown(func() {
stopJobs() stopJobs()

View File

@@ -472,7 +472,7 @@ func (s *BanService) MaliciousPattern(path string) (string, error) {
} }
func shouldSkipMaliciousPath(path string) bool { func shouldSkipMaliciousPath(path string) bool {
return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/") return path == "/health" || strings.HasPrefix(path, "/static/")
} }
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) { func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {

View File

@@ -0,0 +1,166 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var reactionsBucket = []byte("file_reactions")
type ReactionService struct {
db *bbolt.DB
}
type FileReaction struct {
BoxID string `json:"boxId"`
FileID string `json:"fileId"`
EmojiID string `json:"emojiId"`
VisitorHash string `json:"visitorHash"`
CreatedAt time.Time `json:"createdAt"`
}
type ReactionSummary struct {
EmojiID string `json:"emojiId"`
Count int `json:"count"`
}
func NewReactionService(db *bbolt.DB) (*ReactionService, error) {
if err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(reactionsBucket)
return err
}); err != nil {
return nil, err
}
return &ReactionService{db: db}, nil
}
func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) {
boxID = strings.TrimSpace(boxID)
fileID = strings.TrimSpace(fileID)
visitorHash := reactionVisitorHash(visitorID)
emojiID = strings.TrimSpace(emojiID)
if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" {
return nil, errors.New("missing reaction data")
}
reaction := FileReaction{
BoxID: boxID,
FileID: fileID,
EmojiID: emojiID,
VisitorHash: visitorHash,
CreatedAt: time.Now().UTC(),
}
data, err := json.Marshal(reaction)
if err != nil {
return nil, err
}
key := reactionKey(boxID, fileID, visitorHash)
if err := s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket.Get([]byte(key)) != nil {
return os.ErrExist
}
return bucket.Put([]byte(key), data)
}); err != nil {
return nil, err
}
return s.SummaryForFile(boxID, fileID)
}
func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) {
visitorHash := reactionVisitorHash(visitorID)
summaries := make(map[string]map[string]int)
viewerReacted := make(map[string]bool)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID != boxID {
return nil
}
if summaries[reaction.FileID] == nil {
summaries[reaction.FileID] = make(map[string]int)
}
summaries[reaction.FileID][reaction.EmojiID]++
if visitorHash != "" && reaction.VisitorHash == visitorHash {
viewerReacted[reaction.FileID] = true
}
return nil
})
})
if err != nil {
return nil, nil, err
}
result := make(map[string][]ReactionSummary, len(summaries))
for fileID, counts := range summaries {
result[fileID] = reactionCountsToSummaries(counts)
}
return result, viewerReacted, nil
}
func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) {
counts := make(map[string]int)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID == boxID && reaction.FileID == fileID {
counts[reaction.EmojiID]++
}
return nil
})
})
if err != nil {
return nil, err
}
return reactionCountsToSummaries(counts), nil
}
func reactionCountsToSummaries(counts map[string]int) []ReactionSummary {
summaries := make([]ReactionSummary, 0, len(counts))
for emojiID, count := range counts {
summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count})
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Count == summaries[j].Count {
return summaries[i].EmojiID < summaries[j].EmojiID
}
return summaries[i].Count > summaries[j].Count
})
return summaries
}
func reactionKey(boxID, fileID, visitorHash string) string {
return boxID + "\x00" + fileID + "\x00" + visitorHash
}
func reactionVisitorHash(visitorID string) string {
visitorID = strings.TrimSpace(visitorID)
if visitorID == "" {
return ""
}
sum := sha256.Sum256([]byte(visitorID))
return hex.EncodeToString(sum[:])
}

View File

@@ -137,6 +137,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
if err := os.MkdirAll(dbDir, 0o755); err != nil { if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err return nil, err
} }
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
return nil, err
}
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second}) db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
@@ -957,6 +960,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data) return base64.RawURLEncoding.EncodeToString(data)
} }
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) { func hashPassword(password string) (string, string) {
salt := randomID(18) salt := randomID(18)
return salt, passwordHash(salt, password) return salt, passwordHash(salt, password)

View File

@@ -592,31 +592,152 @@
content: "\23F1 "; content: "\23F1 ";
} }
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat /* The file browser becomes a Win98 Explorer window: blue titlebar, grey
buttons that raise on hover and depress when active. */ toolbar, sunken content pane and flat rows. */
:root[data-theme="retro"] .file-browser-window {
border: 1px solid #000000;
border-radius: 0;
background: #c0c0c0;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .file-browser-titlebar {
min-height: 1.8rem;
margin: 3px 3px 0;
padding: 0.2rem 0.45rem;
border: 0;
background: linear-gradient(to right, #000078 0%, #000078 80%, #0f80cd 100%);
color: #ffffff;
}
:root[data-theme="retro"] .file-browser-titlebar strong,
:root[data-theme="retro"] .file-browser-titlebar span {
color: #ffffff;
font-size: 0.78rem;
}
:root[data-theme="retro"] .file-browser-window-actions {
display: none;
}
:root[data-theme="retro"] .file-browser-toolbar {
justify-content: space-between;
margin: 0 3px;
padding: 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
}
:root[data-theme="retro"] .view-toolbar { :root[data-theme="retro"] .view-toolbar {
justify-content: flex-start; justify-content: flex-start;
gap: 2px; gap: 2px;
margin-top: 1rem; margin-top: 0;
padding: 3px; padding: 0;
background: #c0c0c0; background: #c0c0c0;
border: 1px solid #000000; border: 0;
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080; box-shadow: none;
} }
:root[data-theme="retro"] .view-toolbar .button { :root[data-theme="retro"] .view-toolbar .button,
:root[data-theme="retro"] .file-browser-toolbar > .button {
display: inline-grid;
place-items: center;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
box-shadow: none; box-shadow: none;
font-weight: 400; font-weight: 400;
} }
:root[data-theme="retro"] .view-toolbar .button:hover { :root[data-theme="retro"] .view-toolbar .icon-button {
width: 2.2rem;
height: 2rem;
padding: 0;
}
:root[data-theme="retro"] .view-toolbar .icon-button svg {
margin: 0;
display: block;
}
:root[data-theme="retro"] .view-toolbar .button:hover,
:root[data-theme="retro"] .file-browser-toolbar > .button:hover {
background: #c0c0c0; background: #c0c0c0;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
} }
:root[data-theme="retro"] .view-toolbar .button.is-active { :root[data-theme="retro"] .view-toolbar .button.is-active,
:root[data-theme="retro"] .file-browser-toolbar > .button.is-active {
background: #d4d0c8; background: #d4d0c8;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff; box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
} }
:root[data-theme="retro"] .file-browser-head {
margin: 0 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
color: #000000;
text-transform: none;
}
:root[data-theme="retro"] .file-browser {
margin: 0 3px 3px;
border: 1px solid #000000;
background: #ffffff;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .download-item {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
:root[data-theme="retro"] .file-open {
border-radius: 0;
color: #000000;
}
:root[data-theme="retro"] .file-open:hover,
:root[data-theme="retro"] .file-open:focus-visible {
background: transparent;
color: #000000;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within {
background: transparent;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover .file-open,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within .file-open {
outline: 0;
}
:root[data-theme="retro"] .file-media {
border: 0;
border-radius: 0;
background: transparent;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-open {
align-content: start;
justify-content: center;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-media {
justify-self: center;
align-self: start;
}
:root[data-theme="retro"] .file-type,
:root[data-theme="retro"] .file-size,
:root[data-theme="retro"] .file-main small {
color: inherit;
}

View File

@@ -46,35 +46,14 @@
text-decoration: none; text-decoration: none;
} }
.view-toolbar {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.button.is-active {
background: var(--primary);
color: var(--primary-foreground);
}
.file-browser {
transition: opacity 160ms ease;
}
.file-card {
position: relative;
}
.thumb-link { .thumb-link {
display: block;
overflow: hidden;
flex: 0 0 4.75rem; flex: 0 0 4.75rem;
width: 4.75rem; width: 4.75rem;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
display: block;
overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem); border-radius: calc(var(--radius) - 0.2rem);
background: var(--muted); background: var(--muted);
} }
@@ -85,58 +64,610 @@
object-fit: cover; object-fit: cover;
} }
.button.is-active {
background: var(--primary);
color: var(--primary-foreground);
}
.file-browser-window {
overflow: hidden;
margin-top: 1.25rem;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent));
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24);
text-align: left;
}
.file-browser-titlebar {
min-height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 62%, transparent);
}
.file-browser-titlebar > div:first-child {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.file-browser-titlebar strong {
font-size: 0.95rem;
}
.file-browser-titlebar span {
color: var(--muted-foreground);
font-size: 0.78rem;
white-space: nowrap;
}
.file-browser-window-actions {
display: inline-flex;
gap: 0.35rem;
}
.file-browser-window-actions span {
width: 0.72rem;
height: 0.72rem;
border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground));
border-radius: 999px;
background: var(--muted);
}
.file-browser-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 74%, transparent);
}
.view-toolbar {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.view-toolbar .button,
.file-browser-toolbar > .button {
min-height: 2rem;
padding: 0.35rem 0.65rem;
font-size: 0.78rem;
}
.view-toolbar .icon-button {
width: 2.25rem;
padding-inline: 0;
justify-content: center;
}
.view-toolbar svg {
width: 0.95rem;
height: 0.95rem;
}
.file-browser-head {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr) minmax(8rem, 0.32fr);
gap: 0.75rem;
padding: 0.42rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted-foreground);
background: color-mix(in srgb, var(--background) 78%, transparent);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.file-browser-head span:first-child {
grid-column: 2;
}
.file-browser {
display: grid;
gap: 0;
padding: 0.35rem;
transition: opacity 160ms ease;
}
.file-browser .download-item {
display: grid;
min-width: 0;
border: 0;
border-radius: calc(var(--radius) - 0.25rem);
background: transparent;
box-shadow: none;
padding: 0;
transform: none;
}
.file-browser .download-item:hover {
transform: none;
}
.file-card {
position: relative;
padding: 0;
}
.file-reaction-dock {
position: static;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
gap: 0.35rem;
pointer-events: none;
padding-right: 0.65rem;
}
.file-reactions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 0.25rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.reaction-pill {
appearance: none;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 1.6rem;
padding: 0.16rem 0.38rem;
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
border-radius: 999px;
background: color-mix(in srgb, var(--card) 88%, #000);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 700;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
pointer-events: auto;
cursor: pointer;
}
.reaction-pill.is-hidden-summary {
display: none;
}
.reaction-pill img {
width: 1rem;
height: 1rem;
display: block;
}
.reaction-more {
appearance: none;
flex: 0 0 auto;
min-height: 1.6rem;
padding: 0.16rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
border-radius: 999px;
background: color-mix(in srgb, var(--card) 88%, #000);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 800;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
pointer-events: auto;
cursor: pointer;
}
.reaction-pill:hover,
.reaction-pill:focus-visible,
.reaction-more:hover,
.reaction-more:focus-visible {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-button {
width: 2.1rem;
height: 2.1rem;
display: inline-grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--card) 92%, #000);
color: var(--foreground);
opacity: 0;
transform: translateY(0.3rem) scale(0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
transition: opacity 150ms ease, transform 150ms ease, border-color 150ms ease, background 150ms ease;
pointer-events: auto;
}
.reaction-button svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-card:hover .reaction-button,
.file-card:focus-within .reaction-button,
.reaction-button:focus-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.reaction-button:hover,
.reaction-button:focus-visible {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-picker {
position: fixed;
top: 0;
left: 0;
z-index: 70;
width: min(21rem, calc(100vw - 1rem));
}
html.reaction-picker-open,
html.reaction-picker-open body {
overflow: hidden;
touch-action: none;
}
.reaction-picker[hidden] {
display: none;
}
.reaction-picker.is-mobile {
inset: 0;
width: auto;
height: 100dvh;
display: grid;
place-items: end center;
overflow: hidden;
padding: 0.75rem 0.75rem max(1.5rem, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.54);
}
.reaction-picker-panel {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 97%, #000);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.52);
}
.reaction-picker.is-mobile .reaction-picker-panel {
width: min(100%, 34rem);
height: 75dvh;
max-height: 75dvh;
display: flex;
flex-direction: column;
}
.reaction-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem;
border-bottom: 1px solid var(--border);
}
.reaction-picker-close {
min-height: 2rem;
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
}
.reaction-existing {
padding: 0.55rem 0.7rem 0;
}
.reaction-existing small,
.reaction-readonly-note {
display: block;
color: var(--muted-foreground);
font-size: 0.74rem;
font-weight: 700;
}
.reaction-existing-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.4rem;
}
.reaction-readonly-note {
margin: 0;
padding: 0.55rem 0.7rem 0.7rem;
}
.reaction-picker-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
padding: 0.55rem 0.7rem 0;
}
.reaction-tab {
flex: 0 0 auto;
min-height: 1.8rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 700;
}
.reaction-tab.is-active {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-search {
display: block;
padding: 0.55rem 0.7rem;
}
.reaction-search input {
width: 100%;
min-height: 2.15rem;
padding: 0.35rem 0.55rem;
}
.reaction-grid-wrap {
max-height: 18rem;
overflow: auto;
padding: 0 0.7rem 0.7rem;
}
.reaction-picker.is-mobile .reaction-grid-wrap {
max-height: none;
flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.reaction-grid {
display: none;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 0.25rem;
}
.reaction-grid.is-active {
display: grid;
}
.reaction-picker.is-mobile .reaction-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.reaction-emoji {
aspect-ratio: 1;
display: grid;
place-items: center;
min-width: 0;
padding: 0.18rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.25rem);
background: transparent;
}
.reaction-emoji:hover,
.reaction-emoji:focus-visible {
border-color: var(--border);
background: var(--accent);
}
.reaction-emoji[hidden] {
display: none;
}
.reaction-emoji img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
/* A file row behaves like an entry in a desktop file explorer: a small
thumbnail/icon followed by the name and metadata. The whole row is the click
target (raw view of the file). */
.file-open {
min-width: 0;
flex: 1;
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr);
align-items: center;
gap: 0.75rem;
color: var(--foreground);
text-decoration: none;
padding: 0.55rem 0.65rem;
border-radius: calc(var(--radius) - 0.25rem);
}
.file-open:hover,
.file-open:focus-visible {
background: var(--surface-1-hover);
}
.file-media {
flex: 0 0 3rem;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
}
.file-thumb {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.file-icon {
width: 2.1rem;
height: 2.1rem;
display: block;
object-fit: contain;
}
/* Retro (Win98) icons are tiny pixel art — keep them crisp and swap them in
only when the retro theme is active. */
.file-icon-retro {
display: none;
image-rendering: pixelated;
}
[data-theme="retro"] .file-icon-standard {
display: none;
}
[data-theme="retro"] .file-icon-retro {
display: block;
}
.file-main { .file-main {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
flex: 1;
color: var(--foreground); color: var(--foreground);
text-decoration: none; text-decoration: none;
} }
.file-actions { .file-name {
display: inline-flex; min-width: 0;
align-items: center;
gap: 0.5rem;
} }
.preview-action [hidden] { .file-main small {
display: none; display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-type,
.file-size {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
text-align: right;
} }
.file-browser.is-thumbs { .file-browser.is-thumbs {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 0.75rem;
padding: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(8.75rem, 1fr));
}
.file-browser-window.is-icon-view .file-browser-head {
display: none;
} }
.file-browser.is-thumbs .file-card { .file-browser.is-thumbs .file-card {
display: grid; display: grid;
min-height: 13.75rem;
min-width: 0; min-width: 0;
align-content: start; align-content: start;
gap: 0.7rem; gap: 0.5rem;
}
.file-browser.is-list .file-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.32fr);
align-items: center;
min-height: 4.25rem;
cursor: pointer;
}
.file-browser.is-list .file-card:hover,
.file-browser.is-list .file-card:focus-within {
background: var(--surface-1-hover);
}
.file-browser.is-list .file-card:hover .file-open,
.file-browser.is-list .file-card:focus-within .file-open {
background: transparent;
}
.file-browser.is-thumbs .file-open {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 6.75rem auto;
gap: 1rem;
height: 100%;
min-height: 0;
padding: 0.65rem 0.65rem 3.05rem;
text-align: center;
justify-items: center;
align-content: start;
overflow: hidden;
}
.file-browser.is-thumbs .file-media {
width: min(6.75rem, 76%);
height: 6.75rem;
flex-basis: auto;
aspect-ratio: 1;
}
.file-browser.is-thumbs .file-icon {
width: 64%;
height: 64%;
} }
.file-browser.is-thumbs .file-main { .file-browser.is-thumbs .file-main {
width: 100%; width: 100%;
grid-template-columns: 1fr;
gap: 0.25rem;
align-self: start;
padding-top: 0.25rem;
} }
.file-browser.is-thumbs .thumb-link { .file-browser.is-thumbs .file-type,
width: 100%; .file-browser.is-thumbs .file-size {
flex-basis: auto;
}
.file-browser.is-thumbs .button {
width: 100%;
}
.file-browser.is-thumbs .file-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.file-browser.images-only .file-card:not([data-kind="image"]) {
display: none; display: none;
} }
.file-browser.is-thumbs .file-reaction-dock {
position: absolute;
right: 0.6rem;
bottom: 0.65rem;
max-width: calc(100% - 1.2rem);
padding-right: 0;
}
.context-menu { .context-menu {
position: fixed; position: fixed;
z-index: 30; z-index: 30;

View File

@@ -95,6 +95,41 @@
flex: 1; flex: 1;
} }
.file-browser-toolbar {
align-items: stretch;
}
.file-browser-toolbar,
.file-browser-toolbar .view-toolbar {
width: 100%;
}
.file-browser-toolbar .view-toolbar .button,
.file-browser-toolbar > .button {
flex: 1 1 auto;
justify-content: center;
}
.file-browser-toolbar .view-toolbar .icon-button {
flex: 0 0 2.5rem;
}
.file-browser-head {
display: none;
}
.file-open {
grid-template-columns: 3rem minmax(0, 1fr) auto;
}
.file-type {
display: none;
}
.file-browser.is-list .file-card {
grid-template-columns: minmax(0, 1fr) minmax(7rem, auto);
}
h1 { h1 {
font-size: 1.65rem; font-size: 1.65rem;
} }
@@ -213,6 +248,54 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.file-browser-titlebar {
align-items: flex-start;
}
.file-browser-titlebar > div:first-child {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.file-browser {
padding: 0.25rem;
}
.file-open {
grid-template-columns: 2.65rem minmax(0, 1fr);
gap: 0.55rem;
padding: 0.5rem;
}
.file-media {
width: 2.65rem;
height: 2.65rem;
}
.file-size {
display: none;
}
.file-browser.is-list .file-card {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.file-browser.is-list .file-reaction-dock {
justify-content: flex-end;
padding: 0 0.5rem 0.5rem;
}
.file-browser.is-thumbs {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0.5rem;
}
.file-browser.is-thumbs .file-open {
height: 100%;
}
.file-actions, .file-actions,
.file-browser.is-thumbs .file-actions { .file-browser.is-thumbs .file-actions {
width: 100%; width: 100%;
@@ -220,6 +303,16 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side { .file-progress-side {
width: 100%; width: 100%;
} }

View File

@@ -0,0 +1,112 @@
{
"_comment": "Maps a file's type (resolved from its extension / content type) to a file-type icon. 'standard' icons live in file-icons/standard, 'retro' (Win98) icons in file-icons/retro. The server reads this at startup and picks the icon per file; thumbnails always win over icons when present.",
"default": {
"mime": "application/octet-stream",
"standard": "txt-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png"
},
"types": [
{
"mime": "image/*",
"standard": "image-document-svgrepo-com.svg",
"retro": "shimgvw.dll_14_1-2.png",
"extensions": ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff", "heic", "heif", "avif", "jfif"]
},
{
"mime": "image/vnd.adobe.photoshop",
"standard": "psd-document-svgrepo-com.svg",
"retro": "shimgvw.dll_14_1-2.png",
"extensions": ["psd"]
},
{
"mime": "audio/*",
"standard": "audio-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_610-2.png",
"extensions": ["mp3", "wav", "flac", "aac", "ogg", "oga", "m4a", "wma", "opus", "aiff", "aif", "mid", "midi"]
},
{
"mime": "video/mp4",
"standard": "mp4-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_504-2.png",
"extensions": ["mp4", "m4v"]
},
{
"mime": "video/*",
"standard": "video-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_504-2.png",
"extensions": ["mkv", "mov", "avi", "webm", "wmv", "flv", "mpg", "mpeg", "3gp", "ogv", "ts", "m2ts"]
},
{
"mime": "application/zip",
"standard": "zip-document-svgrepo-com.svg",
"retro": "zipfldr.dll_14_101-2.png",
"extensions": ["zip", "rar", "7z", "gz", "tar", "bz2", "xz", "tgz", "zst", "lz", "lzma", "cab", "iso"]
},
{
"mime": "application/pdf",
"standard": "pdf-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["pdf"]
},
{
"mime": "text/html",
"standard": "html-document-svgrepo-com.svg",
"retro": "mshtml.dll_14_2660-2.png",
"extensions": ["html", "htm", "xhtml", "mhtml"]
},
{
"mime": "application/x-shockwave-flash",
"standard": "flash-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["swf", "fla"]
},
{
"mime": "application/vnd.ms-excel",
"standard": "excel-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["xls", "xlsx", "xlsm", "ods"]
},
{
"mime": "text/csv",
"standard": "csv-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["csv", "tsv"]
},
{
"mime": "application/msword",
"standard": "word-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["doc", "docx", "odt"]
},
{
"mime": "application/rtf",
"standard": "rtf-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["rtf"]
},
{
"mime": "application/vnd.apple.pages",
"standard": "pages-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["pages"]
},
{
"mime": "application/vnd.visio",
"standard": "visio-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["vsd", "vsdx"]
},
{
"mime": "application/x-msdownload",
"standard": "exe-document-svgrepo-com.svg",
"retro": "shell32.dll_14_3-0.png",
"extensions": ["exe", "msi", "bat", "cmd", "com", "app", "dmg", "apk", "deb", "rpm", "appimage"]
},
{
"mime": "text/plain",
"standard": "txt-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["txt", "text", "log", "md", "markdown", "ini", "cfg", "conf", "json", "xml", "yaml", "yml", "toml", "js", "ts", "jsx", "tsx", "go", "py", "rb", "php", "java", "c", "h", "cpp", "cc", "cs", "rs", "sh", "bash", "css", "scss", "sql"]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.151.012c-2.802 0-5.073 2.272-5.073 5.073v53.842c0 2.802 2.272 5.073 5.073 5.073h45.774c2.803 0 5.075-2.271 5.075-5.073v-38.606l-18.903-20.309h-31.946z" fill="#379FD3"/>
<path d="M56 20.357v1h-12.8s-6.312-1.26-6.128-6.707c0 0 .208 5.707 6.003 5.707h12.925z" fill="#2987C8"/>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.106 0c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#45B058"/>
<path d="M20.306 43.197c.126.144.198.324.198.522 0 .378-.306.72-.703.72-.18 0-.378-.072-.504-.234-.702-.846-1.891-1.387-3.007-1.387-2.629 0-4.627 2.017-4.627 4.88 0 2.845 1.999 4.879 4.627 4.879 1.134 0 2.25-.486 3.007-1.369.125-.144.324-.233.504-.233.415 0 .703.359.703.738 0 .18-.072.36-.198.504-.937.972-2.215 1.693-4.015 1.693-3.457 0-6.176-2.521-6.176-6.212s2.719-6.212 6.176-6.212c1.8.001 3.096.721 4.015 1.711zm6.802 10.714c-1.782 0-3.187-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.361.27-.757.702-.757.144 0 .306.036.432.144.828.739 1.98 1.314 3.367 1.314 2.143 0 2.827-1.152 2.827-2.071 0-3.097-7.112-1.386-7.112-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.467 3.853 1.278.162.144.252.342.252.54 0 .36-.306.72-.703.72-.144 0-.306-.054-.432-.162-.882-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636.001 1.748-1.187 3.531-4.428 3.531zm16.994-11.254l-4.159 10.335c-.198.486-.685.81-1.188.81h-.036c-.522 0-1.008-.324-1.207-.81l-4.142-10.335c-.036-.09-.054-.18-.054-.288 0-.36.323-.793.81-.793.306 0 .594.18.72.486l3.889 9.992 3.889-9.992c.108-.288.396-.486.72-.486.468 0 .81.378.81.793.001.09-.017.198-.052.288z" fill="#ffffff"/>
<g fill-rule="evenodd" clip-rule="evenodd">

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M5.112.006c-2.802 0-5.073 2.273-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#45B058"/><path d="M19.429 53.938c-.216 0-.415-.09-.54-.27l-3.728-4.97-3.745 4.97c-.126.18-.324.27-.54.27-.396 0-.72-.306-.72-.72 0-.144.035-.306.144-.432l3.89-5.131-3.619-4.826c-.09-.126-.145-.27-.145-.414 0-.342.288-.72.721-.72.216 0 .432.108.576.288l3.438 4.628 3.438-4.646c.127-.18.324-.27.541-.27.378 0 .738.306.738.72 0 .144-.036.288-.127.414l-3.619 4.808 3.891 5.149c.09.126.125.27.125.414 0 .396-.324.738-.719.738zm9.989-.126h-5.455c-.595 0-1.081-.486-1.081-1.08v-10.317c0-.396.324-.72.774-.72.396 0 .721.324.721.72v10.065h5.041c.359 0 .648.288.648.648 0 .396-.289.684-.648.684zm6.982.216c-1.782 0-3.188-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.36.27-.756.702-.756.144 0 .306.036.433.144.828.738 1.98 1.314 3.367 1.314 2.143 0 2.826-1.152 2.826-2.071 0-3.097-7.111-1.386-7.111-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.468 3.853 1.278.162.144.253.342.253.54 0 .36-.307.72-.703.72-.145 0-.307-.054-.432-.162-.883-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636 0 1.748-1.188 3.53-4.43 3.53z" fill="#ffffff"/><path d="M55.953 20.352v1h-12.801s-6.312-1.26-6.127-6.707c0 0 .207 5.707 6.002 5.707h12.926z" fill-rule="evenodd" clip-rule="evenodd" fill="#349C42"/><path d="M37.049 0v14.561c0 1.656 1.104 5.791 6.104 5.791h12.801l-18.905-20.352z" opacity=".5" fill-rule="evenodd" clip-rule="evenodd" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.025c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8199AF"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.961 20.377v1h-12.799s-6.312-1.26-6.129-6.708c0 0 .208 5.708 6.004 5.708h12.924z" fill="#617F9B"/>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.009c-2.802 0-5.073 2.273-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.904-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#E53C3C"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.961 20.346v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#DE2D2D"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.135.008c-2.803 0-5.074 2.272-5.074 5.074v53.84c0 2.803 2.271 5.074 5.074 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#F7622C"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.976 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#F54921"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.125.042c-2.801 0-5.072 2.273-5.072 5.074v53.841c0 2.803 2.271 5.073 5.072 5.073h45.775c2.801 0 5.074-2.271 5.074-5.073v-38.604l-18.904-20.311h-31.945z" fill="#49C9A7"/>
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#37BB91"/>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.116.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.946z" fill="#9B64B2"/>
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#824B9E"/>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.111-.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#6A6AE2"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.976 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#4F4FDA"/>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
width="800px" height="800px" viewBox="0 0 56 64" enable-background="new 0 0 56 64" xml:space="preserve">
<g>
<path fill="#8C181A" d="M5.1,0C2.3,0,0,2.3,0,5.1v53.8C0,61.7,2.3,64,5.1,64h45.8c2.8,0,5.1-2.3,5.1-5.1V20.3L37.1,0H5.1z"/>
<path fill="#6B0D12" d="M56,20.4v1H43.2c0,0-6.3-1.3-6.1-6.7c0,0,0.2,5.7,6,5.7H56z"/>
<path opacity="0.5" fill="#FFFFFF" enable-background="new " d="M37.1,0v14.6c0,1.7,1.1,5.8,6.1,5.8H56L37.1,0z"/>
</g>
<path fill="#FFFFFF" d="M14.9,49h-3.3v4.1c0,0.4-0.3,0.7-0.8,0.7c-0.4,0-0.7-0.3-0.7-0.7V42.9c0-0.6,0.5-1.1,1.1-1.1h3.7
c2.4,0,3.8,1.7,3.8,3.6C18.7,47.4,17.3,49,14.9,49z M14.8,43.1h-3.2v4.6h3.2c1.4,0,2.4-0.9,2.4-2.3C17.2,44,16.2,43.1,14.8,43.1z
M25.2,53.8h-3c-0.6,0-1.1-0.5-1.1-1.1v-9.8c0-0.6,0.5-1.1,1.1-1.1h3c3.7,0,6.2,2.6,6.2,6C31.4,51.2,29,53.8,25.2,53.8z M25.2,43.1
h-2.6v9.3h2.6c2.9,0,4.6-2.1,4.6-4.7C29.9,45.2,28.2,43.1,25.2,43.1z M41.5,43.1h-5.8V47h5.7c0.4,0,0.6,0.3,0.6,0.7
s-0.3,0.6-0.6,0.6h-5.7v4.8c0,0.4-0.3,0.7-0.8,0.7c-0.4,0-0.7-0.3-0.7-0.7V42.9c0-0.6,0.5-1.1,1.1-1.1h6.2c0.4,0,0.6,0.3,0.6,0.7
C42.2,42.8,41.9,43.1,41.5,43.1z"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.051c-2.802 0-5.073 2.273-5.073 5.075v53.841c0 2.802 2.271 5.073 5.073 5.073h45.775c2.801 0 5.074-2.271 5.074-5.073v-38.606l-18.903-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#0C77C6"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#0959B7"/>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.113.006c-2.803 0-5.074 2.273-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#00A1EE"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#0089E9"/>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.151-.036c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#F9CA06"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M56.008 20.316v1h-12.799s-6.312-1.26-6.129-6.708c0 0 .208 5.708 6.004 5.708h12.924z" fill="#F7BC04"/>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.15.011c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.272 5.074 5.072 5.074h45.775c2.802 0 5.075-2.271 5.075-5.074v-38.606l-18.904-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8E4C9E"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#713985"/>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.111.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.606l-18.903-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#496AB3"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#374FA0"/>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd">
<path d="m5.11 0a5.07 5.07 0 0 0 -5.11 5v53.88a5.07 5.07 0 0 0 5.11 5.12h45.78a5.07 5.07 0 0 0 5.11-5.12v-38.6l-18.94-20.28z" fill="#107cad"/>
<path d="m56 20.35v1h-12.82s-6.31-1.26-6.13-6.71c0 0 .21 5.71 6 5.71z" fill="#084968"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.113-.026c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.773c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.901-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8199AF"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#617F9B"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,33 +1,50 @@
(function () { (function () {
const fileBrowser = document.querySelector("[data-file-browser]"); const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]"); const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
const previewActions = document.querySelectorAll("[data-preview-action]"); const previewActions = document.querySelectorAll("[data-preview-action]");
const fileContextMenu = document.querySelector("[data-file-context-menu]"); const fileContextMenu = document.querySelector("[data-file-context-menu]");
const fileBrowserWindow = document.querySelector("[data-file-browser-window]");
let ctrlCopyMode = false; let ctrlCopyMode = false;
let contextFile = null; let contextFile = null;
const contextMenuCloseDistance = 80; const contextMenuCloseDistance = 80;
const viewStorageKey = "warpbox.fileBrowser.view";
if (fileBrowser) { if (fileBrowser) {
applySavedFileBrowserPreferences();
viewButtons.forEach((button) => { viewButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button"); const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list"); setFileBrowserView(view);
fileBrowser.classList.toggle("is-thumbs", view === "thumbs"); savePreference(viewStorageKey, view);
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
}); });
}); });
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
} }
if (fileBrowser && fileContextMenu) { if (fileBrowser && fileContextMenu) {
document.body.appendChild(fileContextMenu);
fileBrowser.addEventListener("click", (event) => {
if (!fileBrowser.classList.contains("is-list")) {
return;
}
if (event.target.closest("a, button, input, select, textarea")) {
return;
}
const card = event.target.closest("[data-file-context]");
const link = card ? card.querySelector(".file-open") : null;
if (!link) {
return;
}
event.preventDefault();
if (link.target === "_blank") {
window.Warpbox.openInNewTab(link.href);
return;
}
window.location.href = link.href;
});
fileBrowser.addEventListener("contextmenu", (event) => { fileBrowser.addEventListener("contextmenu", (event) => {
const card = event.target.closest("[data-file-context]"); const card = event.target.closest("[data-file-context]");
if (!card) { if (!card) {
@@ -188,4 +205,40 @@
y >= rect.top - contextMenuCloseDistance && y >= rect.top - contextMenuCloseDistance &&
y <= rect.bottom + contextMenuCloseDistance; y <= rect.bottom + contextMenuCloseDistance;
} }
function applySavedFileBrowserPreferences() {
const savedView = readPreference(viewStorageKey);
setFileBrowserView(savedView === "list" ? "list" : "thumbs");
}
function setFileBrowserView(view) {
const normalized = view === "thumbs" ? "thumbs" : "list";
fileBrowser.classList.toggle("is-list", normalized === "list");
fileBrowser.classList.toggle("is-thumbs", normalized === "thumbs");
if (fileBrowserWindow) {
fileBrowserWindow.classList.toggle("is-list-view", normalized === "list");
fileBrowserWindow.classList.toggle("is-icon-view", normalized === "thumbs");
}
viewButtons.forEach((item) => {
const active = item.getAttribute("data-view-button") === normalized;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function readPreference(key) {
try {
return window.localStorage.getItem(key);
} catch (_) {
return "";
}
}
function savePreference(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (_) {
// LocalStorage can be unavailable in private or locked-down browsers.
}
}
})(); })();

View File

@@ -0,0 +1,304 @@
(function () {
const picker = document.querySelector("[data-reaction-picker]");
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null;
const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null;
const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null;
const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : [];
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
let activeButton = null;
let activeCard = null;
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openPicker(button);
});
});
document.addEventListener("click", (event) => {
const pill = event.target.closest("[data-reaction-pill]");
if (pill) {
event.preventDefault();
event.stopPropagation();
const card = pill.closest("[data-reaction-card]") || activeCard;
if (!card) {
return;
}
if (card.dataset.reacted === "true") {
openPickerForCard(card, pill);
return;
}
submitReactionForCard(card, pill.dataset.reactionEmojiId);
return;
}
const more = event.target.closest("[data-reaction-more]");
if (!more) {
return;
}
event.preventDefault();
event.stopPropagation();
const card = more.closest("[data-reaction-card]");
if (card) {
openPickerForCard(card, more);
}
});
if (!picker || !panel) {
return;
}
// Aurora's glass card uses backdrop-filter, and the main content animates
// with transform. Both can create a containing block for fixed descendants,
// so keep the floating picker at body level where viewport coordinates mean
// what they say.
document.body.appendChild(picker);
picker.addEventListener("click", (event) => {
if (event.target === picker) {
closePicker();
}
});
panel.addEventListener("click", async (event) => {
const emoji = event.target.closest("[data-emoji-id]");
if (!emoji || !activeCard || activeCard.dataset.reacted === "true") {
return;
}
await submitReactionForCard(activeCard, emoji.dataset.emojiId);
});
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
setActiveTab(tab.dataset.reactionTab);
});
});
if (search) {
search.addEventListener("input", () => filterEmoji(search.value));
}
if (closeButton) {
closeButton.addEventListener("click", closePicker);
}
document.addEventListener("click", (event) => {
if (picker.hidden) {
return;
}
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
return;
}
if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) {
return;
}
closePicker();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePicker();
}
});
window.addEventListener("resize", () => {
if (activeButton && !picker.hidden) {
positionPicker(activeButton);
}
});
function openPicker(button) {
openPickerForCard(button.closest("[data-reaction-card]"), button);
}
function openPickerForCard(card, trigger) {
if (!card) {
return;
}
activeButton = trigger || card.querySelector("[data-reaction-button]");
activeCard = card;
populateExistingReactions(card);
setPickerReadonly(card.dataset.reacted === "true");
picker.hidden = false;
picker.classList.add("is-open");
if (search) {
search.value = "";
filterEmoji("");
}
positionPicker(activeButton || card);
}
function closePicker() {
picker.hidden = true;
picker.classList.remove("is-open", "is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "";
picker.style.top = "";
setPickerReadonly(false);
activeButton = null;
activeCard = null;
}
function positionPicker(button) {
if (isMobilePicker()) {
picker.classList.add("is-mobile");
document.documentElement.classList.add("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
return;
}
picker.classList.remove("is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
const buttonRect = button.getBoundingClientRect();
const pickerRect = panel.getBoundingClientRect();
const margin = 10;
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
const preferredTop = buttonRect.bottom + 8;
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
picker.style.left = `${left}px`;
picker.style.top = `${top}px`;
}
function isMobilePicker() {
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
}
function setActiveTab(tabID) {
tabs.forEach((tab) => {
const active = tab.dataset.reactionTab === tabID;
tab.classList.toggle("is-active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((item) => {
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
});
}
function filterEmoji(value) {
const query = value.trim().toLowerCase();
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
button.hidden = query !== "" && !haystack.includes(query);
});
}
async function submitReactionForCard(card, emojiID) {
if (!card || !emojiID || card.dataset.reacted === "true") {
return;
}
const body = new URLSearchParams();
body.set("emoji_id", emojiID);
const reactButton = card.querySelector("[data-reaction-button]");
if (reactButton) {
reactButton.disabled = true;
}
const response = await fetch(card.dataset.reactUrl, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
if (reactButton) {
reactButton.disabled = false;
}
closePicker();
return;
}
const payload = await response.json();
renderReactions(card, payload.reactions || []);
card.dataset.reacted = "true";
if (reactButton) {
reactButton.remove();
}
closePicker();
}
function renderReactions(card, reactions) {
const list = card.querySelector("[data-reaction-list]");
if (!list) {
return;
}
list.replaceChildren();
reactions.forEach((reaction) => {
const pill = buildReactionPill(reaction);
if (!reaction.visible) {
pill.classList.add("is-hidden-summary");
}
list.append(pill);
});
const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0;
if (hiddenCount > 0) {
const more = document.createElement("button");
more.className = "reaction-more";
more.type = "button";
more.dataset.reactionMore = "";
more.textContent = `+${hiddenCount}`;
more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`);
list.append(more);
}
}
function buildReactionPill(reaction) {
const pill = document.createElement("button");
pill.className = "reaction-pill";
pill.type = "button";
pill.title = reaction.label || reaction.emojiId;
pill.dataset.reactionPill = "";
pill.dataset.reactionEmojiId = reaction.emojiId;
pill.dataset.reactionLabel = reaction.label || reaction.emojiId;
pill.dataset.reactionUrl = reaction.url;
pill.dataset.reactionCount = reaction.count;
pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`);
const image = document.createElement("img");
image.src = reaction.url;
image.alt = reaction.label || reaction.emojiId;
image.loading = "lazy";
const count = document.createElement("span");
count.textContent = reaction.count;
pill.append(image, count);
return pill;
}
function populateExistingReactions(card) {
if (!existingSection || !existingList) {
return;
}
existingList.replaceChildren();
card.querySelectorAll("[data-reaction-pill]").forEach((pill) => {
const clone = pill.cloneNode(true);
clone.classList.remove("is-hidden-summary");
existingList.append(clone);
});
existingSection.hidden = existingList.children.length === 0;
}
function setPickerReadonly(readonly) {
picker.classList.toggle("is-readonly", readonly);
chooserElements.forEach((element) => {
element.hidden = readonly;
});
if (readonlyNote) {
readonlyNote.hidden = !readonly;
}
}
})();

View File

@@ -31,6 +31,7 @@
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/10-file-browser.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/20-storage-admin.js?version={{.AppVersion}}"></script> <script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script> <script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script> <script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>

View File

@@ -14,7 +14,7 @@
<h2>Endpoints</h2> <h2>Endpoints</h2>
<dl class="endpoint-list"> <dl class="endpoint-list">
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div> <div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
<div><dt>Health</dt><dd><code>GET /api/v1/health</code></dd></div> <div><dt>Health</dt><dd><code>GET /health</code></dd></div>
<div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div> <div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div>
<div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div> <div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
</dl> </dl>

View File

@@ -24,51 +24,131 @@
{{end}} {{end}}
{{if .Data.Files}} {{if .Data.Files}}
{{$single := eq (len .Data.Files) 1}}
<div class="badge-row"> <div class="badge-row">
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span> <span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}} {{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
</div> </div>
{{if not .Data.Locked}} {{if not .Data.Locked}}
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}"> {{if $single}}
<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> {{$first := index .Data.Files 0}}
Download zip <a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
</a> <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
</a>
{{else}}
<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>
{{end}}
{{end}} {{end}}
<div class="view-toolbar" aria-label="File view options"> <div class="file-browser-window" data-file-browser-window>
<button class="button button-outline is-active" type="button" data-view-button="list">List</button> <div class="file-browser-titlebar">
<button class="button button-outline" type="button" data-view-button="thumbs">Thumbnails</button> <div>
<button class="button button-outline" type="button" data-preview-images>Preview images only</button> <strong>File Browser</strong>
</div> <span>{{len .Data.Files}} item{{if ne (len .Data.Files) 1}}s{{end}}</span>
</div>
<div class="download-list file-browser is-list" data-file-browser> <div class="file-browser-window-actions" aria-hidden="true">
{{range .Data.Files}} <span></span><span></span><span></span>
<article class="download-item file-card" data-kind="{{.PreviewKind}}" data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}" data-file-name="{{.Name}}"> </div>
<a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}"> </div>
<img src="{{.ThumbnailURL}}" alt="" loading="lazy"> <div class="file-browser-toolbar" aria-label="File view options">
</a> <div class="view-toolbar">
<a class="file-main" href="{{.DownloadURL}}?inline=1"> <button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong> <svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
<small>{{.Size}} · {{.ContentType}}</small> <span class="sr-only">List view</span>
</a> </button>
{{if not $.Data.Locked}} <button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
<div class="file-actions"> <svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
<a class="button button-outline preview-action" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer" data-preview-action data-view-label="View" data-copy-label="Copy link"> <span class="sr-only">Icon view</span>
<svg data-preview-view-icon viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg> </button>
<svg data-preview-copy-icon viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true" hidden><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg> </div>
<span data-preview-label>View</span> </div>
</a> <div class="file-browser-head" aria-hidden="true">
<a class="button button-outline" href="{{.DownloadURL}}" download="{{.Name}}"> <span>Name</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> <span>Type</span>
Download <span>Size</span>
</a> </div>
</div> <div class="download-list file-browser is-thumbs" data-file-browser>
{{end}} {{range .Data.Files}}
</article> <article class="download-item file-card" data-kind="{{.PreviewKind}}" data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}" data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
{{end}} <a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">
<span class="file-media">
{{if .HasThumbnail}}
<img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy">
{{else}}
{{if .IconURL}}<img class="file-icon file-icon-standard" src="{{.IconURL}}" alt="" loading="lazy">{{end}}
{{if .IconRetroURL}}<img class="file-icon file-icon-retro" src="{{.IconRetroURL}}" alt="" loading="lazy">{{end}}
{{end}}
</span>
<span class="file-main">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</span>
<span class="file-type">{{.ContentType}}</span>
<span class="file-size">{{.Size}}</span>
</a>
{{if not $.Data.Locked}}
<div class="file-reaction-dock" data-reaction-dock>
<div class="file-reactions" data-reaction-list>
{{range .Reactions}}
<button class="reaction-pill {{if not .Visible}}is-hidden-summary{{end}}" type="button" title="{{.Label}}" data-reaction-pill data-reaction-emoji-id="{{.EmojiID}}" data-reaction-label="{{.Label}}" data-reaction-url="{{.URL}}" data-reaction-count="{{.Count}}" aria-label="{{if $.Data.Locked}}Reaction{{else}}React with {{.Label}}{{end}}">
<img src="{{.URL}}" alt="{{.Label}}" loading="lazy">
<span>{{.Count}}</span>
</button>
{{end}}
{{if .ReactionMore}}
<button class="reaction-more" type="button" data-reaction-more aria-label="Show all reactions">+{{.ReactionMore}}</button>
{{end}}
</div>
{{if not .Reacted}}
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg>
</button>
{{end}}
</div>
{{end}}
</article>
{{end}}
</div>
</div> </div>
{{if not .Data.Locked}} {{if not .Data.Locked}}
<div class="reaction-picker" data-reaction-picker hidden>
<div class="reaction-picker-panel" role="dialog" aria-modal="false" aria-label="Choose a reaction">
<div class="reaction-picker-head">
<strong>React</strong>
<button class="button button-ghost reaction-picker-close" type="button" data-reaction-close aria-label="Close reaction picker">Close</button>
</div>
<div class="reaction-existing" data-reaction-existing hidden>
<small>Existing reactions</small>
<div class="reaction-existing-list" data-reaction-existing-list></div>
</div>
<p class="reaction-readonly-note" data-reaction-readonly hidden>You already reacted to this file.</p>
<div class="reaction-picker-tabs" role="tablist" aria-label="Emoji themes">
{{range $index, $tab := .Data.EmojiTabs}}
<button type="button" class="reaction-tab {{if eq $index 0}}is-active{{end}}" data-reaction-tab="{{$tab.ID}}" role="tab" aria-selected="{{if eq $index 0}}true{{else}}false{{end}}">{{$tab.Label}}</button>
{{end}}
</div>
<label class="reaction-search">
<span class="sr-only">Search emoji</span>
<input type="search" data-reaction-search placeholder="Search emoji">
</label>
<div class="reaction-grid-wrap">
{{range $index, $tab := .Data.EmojiTabs}}
<div class="reaction-grid {{if eq $index 0}}is-active{{end}}" data-reaction-panel="{{$tab.ID}}" role="tabpanel">
{{range $tab.Emojis}}
<button class="reaction-emoji" type="button" data-emoji-id="{{.ID}}" data-emoji-label="{{.Label}}" title="{{.Label}}" aria-label="{{.Label}}">
<img src="{{.URL}}" alt="" loading="lazy">
</button>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden> <div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden>
<div class="context-menu-top"> <div class="context-menu-top">
<small>File actions</small> <small>File actions</small>

View File

@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60 WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES= WARPBOX_TRUSTED_PROXIES=