7 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
c9f865cd85 refactor(admin): use inline pixel heights for overview charts
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Refactors the admin overview dashboard charts to use inline pixel heights (up to 150px) instead of CSS variables and percentage-based heights. This provides more robust rendering and layout control.

Changes include:
- Replacing `Height` with `HeightPx` in chart bar structures.
- Rendering inline styles for height and width on charts and status bars.
- Adding fallback data attributes (`data-height-px`, `data-chart-value`, etc.) and loading a new fallback script (`25-admin-charts.js`).
- Updating and expanding test coverage to assert correct scaling and HTML rendering.
2026-06-01 12:30:59 +03:00
59 changed files with 2586 additions and 220 deletions

View File

@@ -27,7 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s
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.
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
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`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
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:
```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`, `/app/static`, and `/app/templates`.
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
use `/health`.
The image exposes the health endpoint: `/health`. Docker and compose healthchecks use it.
## Reverse Proxy Security
@@ -106,6 +141,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
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`:

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
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
Active bans return:

View File

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

View File

@@ -1,6 +1,9 @@
package config
import "testing"
import (
"testing"
"time"
)
func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{
@@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) {
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

@@ -707,24 +707,60 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 100
want = 150
}
if bar.Height != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
if bar.HeightPx != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
for i, bar := range overview.StorageDays {
want := 0
if i == len(overview.StorageDays)-1 {
want = 100
want = 150
}
if bar.Height != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
if bar.HeightPx != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
if overview.StatusBars[0].WidthPercent != 100 {
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
}
}
func TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
yesterday := today.AddDate(0, 0, -1)
twoDaysAgo := today.AddDate(0, 0, -2)
boxes := []services.AdminBox{
{ID: "today-1", CreatedAt: today, TotalSize: 30},
{ID: "today-2", CreatedAt: today, TotalSize: 30},
{ID: "today-3", CreatedAt: today, TotalSize: 30},
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
}
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
last := len(overview.UploadDays) - 1
if overview.UploadDays[last].HeightPx != 150 {
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
}
if overview.UploadDays[last-1].HeightPx != 100 {
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
}
if overview.UploadDays[last-2].HeightPx != 50 {
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
}
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
}
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
}
}
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
@@ -738,11 +774,26 @@ func TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "--bar-height: 100%") {
t.Fatalf("admin overview did not render a full-height bar: %s", body)
if !strings.Contains(body, `style="height: 150px"`) {
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
}
if strings.Contains(body, `style="height:`) {
t.Fatalf("admin overview still uses fragile percent height styles: %s", body)
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
}
if !strings.Contains(body, `style="height: 0px"`) {
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
}
if !strings.Contains(body, `style="width: 100%"`) {
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
}
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
}
if strings.Contains(body, "--bar-height") {
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
}
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
t.Fatalf("admin overview did not load chart fallback script: %s", body)
}
}

View File

@@ -159,15 +159,17 @@ type adminOverview struct {
}
type adminChartBar struct {
Label string
Value string
Height int // 0-100, percent of the tallest bar
Label string
Value string
HeightPx int
RawValue int64
}
type adminStatBar struct {
Label string
Value string
Percent int
Label string
Value string
RawValue int
WidthPercent int
}
type adminBoxView struct {
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
// status distributions for the overview dashboard.
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
const days = 14
const chartMaxHeightPx = 150
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
@@ -374,14 +377,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
storageDays := make([]adminChartBar, days)
for i := 0; i < days; i++ {
uploadDays[i] = adminChartBar{
Label: labels[i],
Value: strconv.Itoa(counts[i]),
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
Label: labels[i],
Value: strconv.Itoa(counts[i]),
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
RawValue: int64(counts[i]),
}
storageDays[i] = adminChartBar{
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
Height: scaleHeight(bytes[i], maxBytes),
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
RawValue: bytes[i],
}
}
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
if activeBoxes < 0 {
activeBoxes = 0
}
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
}
return adminOverview{
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
}
}
func scaleHeight(value, max int64) int {
func scaleHeightPx(value, max int64, maxHeightPx int) int {
if max <= 0 || value <= 0 {
return 0
}
height := int(value * 100 / max)
if height < 4 {
height = 4
height := int(value * int64(maxHeightPx) / max)
if height < 8 {
height = 8
}
if height > maxHeightPx {
return maxHeightPx
}
return height
}
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
return value * 100 / total
}
func maxInt(values ...int) int {
max := 0
for _, value := range values {
if value > max {
max = value
}
}
return max
}
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
@@ -1751,7 +1770,7 @@ func isHealthCheckLogEntry(raw map[string]any) bool {
if idx := strings.IndexByte(path, '?'); idx >= 0 {
path = path[:idx]
}
return path == "/health" || path == "/healthz" || path == "/api/v1/health"
return path == "/health"
}
func logEntryFromMap(raw map[string]any) adminLogEntry {

View File

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

View File

@@ -16,12 +16,18 @@ type App struct {
uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService
rateLimiter *rateLimiter
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{
cfg: cfg,
logger: logger,
@@ -29,9 +35,11 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService,
authService: authService,
settingsService: settingsService,
reactionService: reactionService,
banService: banService,
rateLimiter: newRateLimiter(),
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("POST /d/{boxID}/unlock", a.UnlockBox)
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}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("GET /healthz", notFound)
mux.HandleFunc("GET /api/v1/health", notFound)
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-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
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 (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -26,6 +29,7 @@ type downloadPageData struct {
DownloadCount int
MaxDownloads int
ExpiresLabel string
EmojiTabs []emojiTabView
}
type boxView struct {
@@ -41,6 +45,33 @@ type fileView struct {
URL string
DownloadURL 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 {
@@ -70,13 +101,22 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return
}
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))
if !(locked && box.Obfuscate) {
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")
title := "Shared files on Warpbox"
@@ -99,6 +139,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
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)...)
@@ -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 {
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{
ID: file.ID,
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),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", 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 {
if !a.uploadService.IsProtected(box) {
return true

View File

@@ -13,6 +13,10 @@ type healthResponse struct {
}
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{
Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339),

View File

@@ -13,16 +13,20 @@ func TestHealthRoutes(t *testing.T) {
mux := http.NewServeMux()
app.RegisterRoutes(mux)
for _, path := range []string{"/health", "/healthz", "/api/v1/health"} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
})
if response.Code != http.StatusOK {
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 (
"net/http"
"os"
"path/filepath"
"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) {
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) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -198,6 +234,14 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil {
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)
if err != nil {
service.Close()
@@ -213,12 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close()
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())
if err != nil {
service.Close()
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 {
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()
return nil, err
}
reactionService, err := services.NewReactionService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
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()
app.RegisterRoutes(router)
@@ -54,11 +59,12 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
)
server := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}
server.RegisterOnShutdown(func() {
stopJobs()

View File

@@ -472,7 +472,7 @@ func (s *BanService) MaliciousPattern(path string) (string, error) {
}
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) {

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 {
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})
if err != nil {
@@ -957,6 +960,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) {
salt := randomID(18)
return salt, passwordHash(salt, password)

View File

@@ -592,31 +592,152 @@
content: "\23F1 ";
}
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat
buttons that raise on hover and depress when active. */
/* The file browser becomes a Win98 Explorer window: blue titlebar, grey
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 {
justify-content: flex-start;
gap: 2px;
margin-top: 1rem;
padding: 3px;
margin-top: 0;
padding: 0;
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
border: 0;
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;
border: 1px solid transparent;
box-shadow: none;
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;
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;
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;
}
.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 {
display: block;
overflow: hidden;
flex: 0 0 4.75rem;
width: 4.75rem;
aspect-ratio: 16 / 10;
display: block;
overflow: hidden;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
border-radius: calc(var(--radius) - 0.2rem);
background: var(--muted);
}
@@ -85,58 +64,610 @@
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 {
min-width: 0;
max-width: 100%;
flex: 1;
color: var(--foreground);
text-decoration: none;
}
.file-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
.file-name {
min-width: 0;
}
.preview-action [hidden] {
display: none;
.file-main small {
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 {
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 {
display: grid;
min-height: 13.75rem;
min-width: 0;
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 {
width: 100%;
grid-template-columns: 1fr;
gap: 0.25rem;
align-self: start;
padding-top: 0.25rem;
}
.file-browser.is-thumbs .thumb-link {
width: 100%;
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"]) {
.file-browser.is-thumbs .file-type,
.file-browser.is-thumbs .file-size {
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 {
position: fixed;
z-index: 30;

View File

@@ -198,9 +198,9 @@
.bar-chart {
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: stretch;
align-items: end;
gap: 0.4rem;
min-height: 15rem;
min-height: 13rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
@@ -214,11 +214,13 @@
}
.bar-chart-track {
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
flex: 1 1 auto;
width: 100%;
max-width: 1.8rem;
min-height: 9rem;
height: 150px;
margin: 0 auto;
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
border-radius: 0.45rem 0.45rem 0 0;
@@ -228,12 +230,8 @@
.bar-chart-bar {
display: block;
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: var(--bar-height, 0%);
min-height: 0;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
@@ -278,6 +276,8 @@
}
.stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
@@ -288,6 +288,7 @@
.stat-bar-fill {
display: block;
height: 100%;
min-width: 0;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}

View File

@@ -95,6 +95,41 @@
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 {
font-size: 1.65rem;
}
@@ -213,6 +248,54 @@
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-browser.is-thumbs .file-actions {
width: 100%;
@@ -220,6 +303,16 @@
grid-template-columns: 1fr;
}
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side {
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 () {
const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
const previewActions = document.querySelectorAll("[data-preview-action]");
const fileContextMenu = document.querySelector("[data-file-context-menu]");
const fileBrowserWindow = document.querySelector("[data-file-browser-window]");
let ctrlCopyMode = false;
let contextFile = null;
const contextMenuCloseDistance = 80;
const viewStorageKey = "warpbox.fileBrowser.view";
if (fileBrowser) {
applySavedFileBrowserPreferences();
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list");
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
setFileBrowserView(view);
savePreference(viewStorageKey, view);
});
});
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
}
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) => {
const card = event.target.closest("[data-file-context]");
if (!card) {
@@ -188,4 +205,40 @@
y >= rect.top - 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

@@ -0,0 +1,57 @@
(function () {
const maxBarHeight = 150;
function numberAttr(element, name) {
const value = Number(element.getAttribute(name));
return Number.isFinite(value) ? value : 0;
}
function applyChartBars() {
document.querySelectorAll(".bar-chart").forEach((chart) => {
const bars = Array.from(chart.querySelectorAll(".bar-chart-col"));
const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value")));
bars.forEach((bar) => {
const fill = bar.querySelector(".bar-chart-bar");
if (!fill) {
return;
}
const value = numberAttr(bar, "data-chart-value");
let height = numberAttr(fill, "data-height-px");
if (maxValue > 0) {
height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight));
}
fill.style.height = `${Math.min(maxBarHeight, height)}px`;
});
});
}
function applyStatusBars() {
const rows = Array.from(document.querySelectorAll(".stat-bar"));
const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value")));
rows.forEach((row) => {
const fill = row.querySelector(".stat-bar-fill");
if (!fill) {
return;
}
const value = numberAttr(row, "data-stat-value");
let width = numberAttr(fill, "data-width-percent");
if (maxValue > 0) {
width = value <= 0 ? 0 : Math.round((value / maxValue) * 100);
}
fill.style.width = `${Math.max(0, Math.min(100, width))}%`;
});
}
function init() {
applyChartBars();
applyStatusBars();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -31,7 +31,9 @@
<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/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/25-admin-charts.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>

View File

@@ -65,9 +65,9 @@
<p class="muted-copy">New boxes created over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
{{range .Data.Overview.UploadDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}
@@ -81,9 +81,9 @@
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars">
{{range .Data.Overview.StatusBars}}
<div class="stat-bar">
<div class="stat-bar" data-stat-value="{{.RawValue}}">
<span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
</div>
{{end}}
</div>
@@ -97,9 +97,9 @@
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
{{range .Data.Overview.StorageDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}

View File

@@ -14,7 +14,7 @@
<h2>Endpoints</h2>
<dl class="endpoint-list">
<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>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
</dl>

View File

@@ -24,51 +24,131 @@
{{end}}
{{if .Data.Files}}
{{$single := eq (len .Data.Files) 1}}
<div class="badge-row">
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
</div>
{{if not .Data.Locked}}
<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>
{{if $single}}
{{$first := index .Data.Files 0}}
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
<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}}
<div class="view-toolbar" aria-label="File view options">
<button class="button button-outline is-active" type="button" data-view-button="list">List</button>
<button class="button button-outline" type="button" data-view-button="thumbs">Thumbnails</button>
<button class="button button-outline" type="button" data-preview-images>Preview images only</button>
</div>
<div class="download-list file-browser is-list" data-file-browser>
{{range .Data.Files}}
<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}}">
<a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
</a>
<a class="file-main" href="{{.DownloadURL}}?inline=1">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</a>
{{if not $.Data.Locked}}
<div class="file-actions">
<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">
<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>
<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>
<span data-preview-label>View</span>
</a>
<a class="button button-outline" href="{{.DownloadURL}}" download="{{.Name}}">
<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>
</div>
{{end}}
</article>
{{end}}
<div class="file-browser-window" data-file-browser-window>
<div class="file-browser-titlebar">
<div>
<strong>File Browser</strong>
<span>{{len .Data.Files}} item{{if ne (len .Data.Files) 1}}s{{end}}</span>
</div>
<div class="file-browser-window-actions" aria-hidden="true">
<span></span><span></span><span></span>
</div>
</div>
<div class="file-browser-toolbar" aria-label="File view options">
<div class="view-toolbar">
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
<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>
<span class="sr-only">List view</span>
</button>
<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">
<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>
<span class="sr-only">Icon view</span>
</button>
</div>
</div>
<div class="file-browser-head" aria-hidden="true">
<span>Name</span>
<span>Type</span>
<span>Size</span>
</div>
<div class="download-list file-browser is-thumbs" data-file-browser>
{{range .Data.Files}}
<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}}">
<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>
{{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-top">
<small>File actions</small>

View File

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