refactor(code): Cleaned-up the code base

This commit is contained in:
2026-04-30 11:05:56 +03:00
parent a729b641b2
commit f0b723e35d
71 changed files with 6848 additions and 5394 deletions

41
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,41 @@
# Code of Conduct
## Expected Conduct
- Treat contributors and users with respect.
- Assume good intent, especially during review.
- Keep feedback specific, actionable, and focused on the work.
- Be patient with different experience levels and communication styles.
- No political opinions are allowed no matter what.
## Unacceptable Conduct
- Harassment, threats, intimidation, or stalking.
- Abusive, insulting, or demeaning comments.
- Discriminatory language or behavior.
- Publishing private information without permission.
- Sustained disruption of project discussion or review.
## Scope
This code of conduct applies in project spaces, including issues, pull
requests, discussions, commits, documentation, chat, and any other official
project channel.
## Reporting
Report concerns to the maintainers.
Contact placeholder:
```text
TODO: add maintainer contact address
```
If the report involves a maintainer, send it to another trusted maintainer when
available.
## Enforcement
Maintainers may remove comments, close threads, reject contributions, block
participants, or take other reasonable action to keep the project productive.

125
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,125 @@
# Contributing to WarpBox
WarpBox is a small Go application with server-rendered HTML, vanilla
JavaScript, static CSS, local filesystem storage, and BadgerDB metadata. Keep
changes boring, readable, and easy to review.
## Setup
Requirements:
- Go 1.23 or newer, matching `go.mod`.
- No frontend toolchain. Do not add npm, Vite, React, TypeScript, Sass,
Tailwind, or a JavaScript build step for cleanup work.
Run the app:
```bash
go run ./cmd run
```
Run on another address:
```bash
go run ./cmd run --addr :3000
```
## Tests and Checks
Run tests:
```bash
./test.sh
```
Run formatting, vet, and tests:
```bash
./check.sh
```
Both scripts honor `GO_BIN`:
```bash
GO_BIN=/path/to/go ./check.sh
```
If a command cannot run in your environment, say why and include the command
that should be run locally.
## Commit Style
Use Conventional Commits:
```text
type(scope): short imperative subject
```
Types:
- `feat` user-visible feature
- `fix` bug fix
- `refactor` behavior-preserving code change
- `test` tests only
- `docs` documentation only
- `style` formatting or CSS-only visual style when behavior unchanged
- `chore` tooling, dependency, build, housekeeping
- `perf` performance change
- `ci` CI config
Rules:
- Keep subject at 72 characters or less, preferably 50 or less.
- Use imperative mood.
- Keep one concern per commit.
- Make cleanup commits behavior preserving unless the subject says `fix`.
- Mention tests run in the PR description or commit body when useful.
Examples:
```text
docs(contributing): add cleanup rules
refactor(server): split upload handlers
fix(config): reject negative expiry values
```
## Code Review Expectations
Reviews should focus on behavior, safety, and maintainability:
- Confirm routes, environment variables, API response shapes, manifest fields,
and storage layout remain compatible unless the change explicitly updates
them.
- Check that cleanup keeps behavior unchanged and is small enough to review.
- Prefer narrow helpers and clear file ownership over clever abstraction.
- Ask for tests when behavior changes or risk is not obvious.
- Call out missing checks, unclear edge cases, concurrency risks, and security
risks.
## PR Checklist
Before opening or merging a PR:
- Scope is limited to one concern.
- Runtime behavior is unchanged for cleanup PRs.
- Public routes are unchanged unless intentional.
- Environment variables are unchanged unless intentional.
- API response shapes are unchanged unless intentional.
- Manifest JSON field names are unchanged unless intentional.
- Storage directory layout is unchanged unless intentional.
- No frontend build tooling was added.
- Tests or checks run are listed.
## Coding Standards Summary
- Go: small functions, clear errors, stable exported names, no unrelated
package moves.
- JavaScript: vanilla browser scripts, no build step, explicit state ownership,
small modules when files are split.
- CSS: keep shared styles shared, page styles page-scoped, avoid duplicated
popup/window rules.
- Templates: keep server-rendered HTML simple and routes stable.
- Comments: explain behavior rules, edge cases, concurrency, security, or
product choices. Do not restate obvious code.
See [DEVELOPMENT.md](DEVELOPMENT.md) for cleanup rules.

183
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,183 @@
# WarpBox Development Rules
This guide exists for contributors and LLM agents doing behavior-preserving
cleanup. It complements [docs/tech.md](docs/tech.md), which maps the current
implementation.
## Cleanup Principles
- Keep systems boring and obvious.
- Prefer short files grouped by one clear responsibility.
- Prefer narrow helpers over clever abstraction.
- Keep related functions physically close.
- Split files when one file mixes multiple domains.
- Avoid huge utility drawers where unrelated helpers gather.
- Do behavior-preserving cleanup before feature work.
- Use tests before and after each cleanup slice.
Cleanup is not feature work. Do not change runtime behavior unless the task
explicitly says to fix behavior.
## File Responsibility Goals
Files should not mix:
- UI and state.
- Transport and rendering.
- Validation and routing.
- Filesystem operations and business rules.
- Admin workflows and public box workflows.
When a file is large and contains multiple concerns, prefer splitting by
responsibility. Use comment regions only when a file is cohesive and splitting
would make it harder to follow.
Previously split cleanup targets:
- `static/js/app.js` now bootstraps `static/js/upload/`.
- `static/css/upload.css` now lives under `static/css/upload/` and `static/css/components/`.
- `lib/server/handlers.go` is split by handler responsibility.
- `lib/boxstore/store.go` is split by storage responsibility.
- `lib/server/admin.go` is split by admin responsibility.
- `lib/config/config.go` is split by config responsibility.
Do not refactor multiple systems in one cleanup slice.
## Comment and JSDoc Guidance
Add comments for:
- Behavior rules that are easy to break.
- Edge cases.
- Concurrency or background worker behavior.
- Security-sensitive choices.
- Non-obvious product decisions.
Avoid comments that restate code. Prefer clear names and small functions first.
Use JSDoc only when it clarifies non-obvious inputs, outputs, or side effects.
## Go Rules
- Keep public routes stable.
- Keep environment variable names stable.
- Keep API response shapes stable.
- Keep manifest JSON field names stable.
- Keep storage directory layout stable.
- Keep handler files focused on one handler category.
- Keep route registration separate from validation and business logic.
- Return clear wrapped errors when context helps debugging.
- Avoid package moves unless the cleanup slice is specifically about package
ownership.
- Run `gofmt`, `go vet`, and `go test` through `./check.sh`.
Server handler files:
- `pages.go`
- `downloads.go`
- `uploads.go`
- `box_auth.go`
- `validation.go`
- `retention.go`
Keep `lib/server/handlers.go` absent unless there is a deliberate reason to
reintroduce a cohesive handler file.
## JavaScript Rules
- Use vanilla JavaScript only.
- Do not add a build step.
- Keep browser scripts loaded directly by templates.
- Avoid new globals; centralize mutable upload state when splitting.
- Keep DOM queries/rendering separate from API calls and upload orchestration.
- Prefer an action map over long action `if` chains when cleaning event code.
- Share generic UI helpers through `static/js/warpbox-ui.js`.
- Preserve existing data attributes and template contracts unless explicitly
changing behavior.
Target upload split when that cleanup slice is chosen:
- `static/js/upload/state.js`
- `static/js/upload/dom.js`
- `static/js/upload/files.js`
- `static/js/upload/api.js`
- `static/js/upload/upload-flow.js`
- `static/js/upload/options.js`
- `static/js/upload/popups.js`
- `static/js/upload/terminal.js`
- `static/js/upload/events.js`
- `static/js/app.js` as bootstrap only
## CSS Rules
- Keep shared styles in shared files.
- Keep page-specific styles page-scoped.
- Avoid duplicated popup, toast, button, and window rules.
- Use page prefixes for page styles:
- `upload-`
- `box-`
- `admin-`
- Keep visual changes out of behavior-preserving cleanup unless the cleanup
slice is CSS-only.
- Preserve template class names unless the same slice updates every use.
Target CSS split when that cleanup slice is chosen:
- `base.css`
- `window.css`
- `components/buttons.css`
- `components/popups.css`
- `components/toast.css`
- `upload/layout.css`
- `upload/queue.css`
- `upload/options.css`
- `upload/dialogs.css`
- `upload/responsive.css`
- `box.css`
- `admin.css`
## Template Rules
- Keep server-rendered HTML simple.
- Do not rename public routes during cleanup.
- Do not change form field names or data attributes unless the matching Go and
JavaScript code changes in the same slice.
- Keep static CSS and JS loading explicit.
- Avoid hidden behavior changes through template conditionals.
Current loading model:
- Go loads templates from `templates/*.html`.
- Gin serves `/static` from `./static` with gzip middleware.
- Templates link CSS directly from `/static/css/...`.
- Templates load browser JavaScript directly from `/static/js/...`.
- There is no JavaScript or CSS build step.
## Safety Rules
- Inspect repository structure before editing.
- Identify relevant files for the current cleanup slice.
- Identify how JavaScript and CSS are loaded before frontend cleanup.
- Identify available test/check commands before editing.
- Summarize the intended change before editing.
- Make the smallest useful change.
- Do not rename public routes.
- Do not rename environment variables.
- Do not change API response shapes.
- Do not change manifest JSON field names.
- Do not change storage directory layout.
- Do not add frontend tooling during cleanup.
- Do not rewrite working code just to make it look different.
- Do not mix unrelated cleanup areas in one change.
- Do not claim tests passed unless they actually ran.
## Definition of Done
For each cleanup slice:
- Change scope matches one cleanup area.
- Behavior is unchanged unless the slice is explicitly a fix.
- Files have clearer responsibility than before.
- Comments explain only non-obvious rules or risks.
- Tests or checks were run and recorded.
- Any failed command is recorded with the exact reason.
- Next cleanup slice is clear.

190
LICENSE Normal file
View File

@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
Copyright 2026 Daniel Legt
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

4
NOTICE Normal file
View File

@@ -0,0 +1,4 @@
WarpBox
Copyright (c) 2026 Daniel Legt
This product includes software developed by Daniel Legt.

2
TRADEMARK.md Normal file
View File

@@ -0,0 +1,2 @@
The name "WarpBox" and associated branding are not licensed under the Apache License 2.0.
You may not use them without permission.

710
admin-overview.md Normal file
View File

@@ -0,0 +1,710 @@
# WarpBox Admin Overview
This document maps the current WarpBox admin area, explains how the existing pages and permission model work, then proposes an overhaul path for a more useful admin product: dashboard, account management, API keys, and a role/group based authorization system.
## Project Context
WarpBox is a small self-hosted file sharing app. Its core product is deliberately simple:
- create temporary upload boxes
- upload one or more files
- optionally protect the box with a password
- share a generated link
- allow individual downloads or ZIP downloads
- support one-time ZIP handoff
- store uploads on the local filesystem
- store app metadata in local BadgerDB
The project has a strong retro desktop identity. The admin area should keep that personality, but become more operationally useful. Best direction: retro surface, modern information architecture. Admin actions should feel clear, forgiving, and inspectable rather than dense or mysterious.
## Current Admin Architecture
Admin routes are registered under `/admin` in `lib/server/admin.go`.
Current pages:
- `/admin/login`
- `/admin`
- `/admin/boxes`
- `/admin/users`
- `/admin/tags`
- `/admin/settings`
Admin templates live in:
- `templates/admin_login.html`
- `templates/admin.html`
- `templates/admin_boxes.html`
- `templates/admin_users.html`
- `templates/admin_tags.html`
- `templates/admin_settings.html`
Styling uses:
- `static/css/admin.css`
- shared retro UI styles from `static/css/app.css` and `static/css/window.css`
Metadata lives in BadgerDB through `lib/metastore`. Current admin-relevant records are:
- users
- tags
- sessions
- settings overrides
## Current Login And Session Flow
Admin login is bootstrapped from environment configuration:
- `WARPBOX_ADMIN_PASSWORD`
- `WARPBOX_ADMIN_USERNAME`
- `WARPBOX_ADMIN_EMAIL`
- `WARPBOX_ADMIN_ENABLED`
On startup, `BootstrapAdmin` ensures a protected `admin` tag exists. If a user matching the admin username already exists, the admin tag is attached to that user. If no user exists and `WARPBOX_ADMIN_PASSWORD` is set, a first admin user is created.
Login behavior:
- `GET /admin/login` shows login form when admin login is enabled.
- `POST /admin/login` checks username/password.
- password hashes use bcrypt.
- disabled users cannot log in.
- user must have effective `AdminAccess`.
- successful login creates a BadgerDB session with random session token and CSRF token.
- session cookie name is `warpbox_admin_session`.
- cookie path is `/admin`.
- cookie is HTTP-only.
- cookie Secure flag is controlled by `WARPBOX_ADMIN_COOKIE_SECURE`.
- session TTL is controlled by `WARPBOX_SESSION_TTL_SECONDS`.
All protected admin routes require:
- valid session cookie
- unexpired session
- valid CSRF token for non-GET requests
- existing enabled user
- effective `AdminAccess`
Admin responses set no-store headers and `X-Content-Type-Options: nosniff`.
## Current Dashboard Page
Route:
- `GET /admin`
Template:
- `templates/admin.html`
Current behavior:
- shows signed-in username
- provides logout button
- shows four large links:
- Boxes
- Users
- Tags
- Settings
Current limitation:
- this is not a dashboard yet. It has no statistics, recent activity, health status, quota information, user counts, storage totals, warnings, or next actions.
Good future role:
- make this the admin home screen, with live operational summary and clear links into deeper management pages.
## Current Boxes Page
Route:
- `GET /admin/boxes`
Required permission:
- `AdminBoxesView`
Current behavior:
- loads all box summaries from filesystem via `boxstore.ListBoxSummaries()`
- shows summary counters:
- total boxes
- total storage
- expired boxes
- table columns:
- box id
- file count
- size
- created time
- expiry time
- flags
- flags include:
- expired
- one-time
- password
- box id links to `/box/{id}`
Current limitations:
- no search
- no filters
- no pagination
- no delete action
- no bulk cleanup
- no storage trend
- no per-box detail drawer
- no visibility into failed uploads, incomplete boxes, thumbnail state, or one-time consumption state
- expired boxes are counted but not directly actionable
Good future role:
- operational file-sharing monitor and cleanup center.
## Current Users Page
Routes:
- `GET /admin/users`
- `POST /admin/users`
Required permission:
- `AdminUsersManage`
Current behavior:
- create user with username, email, password, and selected tags
- list users sorted by username
- show username, email, assigned tags, created time, status
- enable/disable user
- active session user cannot disable themselves
Current user model:
- `ID`
- `Username`
- `Email`
- `PasswordHash`
- `TagIDs`
- `CreatedAt`
- `UpdatedAt`
- `Disabled`
- optional per-user max file size
- optional per-user max box size
- optional per-user max expiry seconds
Current limitations:
- no edit user page
- no password reset flow
- no email verification or invite flow
- no delete/archive user action
- no user detail screen
- no role preview
- no effective permissions display
- no API key management
- no account activity
- no per-user storage/upload stats
- per-user limit fields exist in the model but are not exposed in the admin UI
Good future role:
- user account command center, optimized for quick scanning, safe edits, and permission clarity.
## Current Tags Page
Routes:
- `GET /admin/tags`
- `POST /admin/tags`
Required permission:
- `AdminUsersManage`
Current behavior:
- create a tag
- set description
- attach permission booleans
- attach optional limits
- list existing tags
- built-in `admin` tag is protected and forced to full admin permissions
Current tag permissions:
- upload allowed
- one-time download allowed
- ZIP download allowed
- renewable allowed
- admin access
- manage users
- manage settings
- view boxes
- max file size bytes
- max box size bytes
- allowed expiry seconds
- renew on access seconds exists in model but is not exposed in current tag form
- renew on download seconds exists in model but is not exposed in current tag form
Permission resolution:
- user permissions are resolved from all assigned tags plus user-level overrides
- boolean permissions are additive: any tag can grant a permission
- size limits use the more permissive value
- global max file/box limits still cap resolved user limits
- allowed expiry seconds are merged from all tags and sorted
- global feature flags can still disable ZIP or one-time downloads even if a tag allows them
Current limitations:
- tags mix labels, roles, groups, limits, and admin capability grants in one concept
- no edit tag page
- no delete tag action
- no user count per tag
- no permission preview
- no conflict detection
- no clear distinction between "role grants access" and "plan defines quotas"
- permission logic is powerful but hard to explain to administrators
Good future role:
- replace tags with explicit roles/groups/plans. Keep old tags only as migration input.
## Current Settings Page
Routes:
- `GET /admin/settings`
- `POST /admin/settings`
Required permission:
- `AdminSettingsManage`
Current behavior:
- shows configured settings table
- columns:
- setting
- value
- source
- environment variable name
- editable values can be overridden from admin UI when `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE=true`
- overrides are stored in BadgerDB
- runtime config is updated after save
Editable settings currently include:
- guest uploads enabled
- API enabled
- ZIP downloads enabled
- one-time downloads enabled
- one-time download expiry seconds
- renew on access enabled
- renew on download enabled
- default guest expiry seconds
- max guest expiry seconds
- default user max file size bytes
- default user max box size bytes
- session TTL seconds
- box poll interval milliseconds
- thumbnail batch size
- thumbnail interval seconds
Non-editable/hard settings include:
- data directory
- global max file size bytes
- global max box size bytes
- one-time download retry on failure
Current limitations:
- settings are presented as raw technical rows
- no grouping
- no descriptions
- no validation hints beyond backend errors
- byte fields require raw bytes
- duration fields require raw seconds/milliseconds
- no "restart required" or "runtime applied" distinction beyond current editability
- no audit trail for setting changes
Good future role:
- organized system configuration, with human units, clear safety boundaries, and change history.
## Current API Key State
There is visible API key UX in the upload page:
- "Use API key for larger quota"
- API key input
- local validation with regex
- value saved locally in browser localStorage
- cURL command includes `Authorization: Bearer YOUR_API_KEY` when enabled
Important current reality:
- no backend API key model was found
- no API key generation route was found
- no server-side Authorization bearer validation was found
- no per-user API key ownership or revocation exists yet
- no current upload flow applies user permissions from an API key
So API keys are a product placeholder, not a functional feature yet.
## Main Current Gaps
- Dashboard is only navigation.
- User management only creates and disables accounts.
- Tags are doing too many jobs.
- API keys are UI-only.
- Admin pages lack search, filters, pagination, and detail views.
- No audit log.
- No admin-visible system health.
- No storage cleanup controls.
- No account self-service area for non-admin users.
- Existing permission model is additive and permissive, which can surprise admins.
## Recommended Overhaul Direction
Build a full admin solution around four clear concepts:
- Dashboard: current health and useful actions.
- Accounts: people who can sign in or use API keys.
- Roles/Groups: permission bundles and admin capabilities.
- Plans/Limits: quotas and upload policy.
This separates identity from authorization from quotas. It should make the system easier to explain and safer to operate.
## Proposed Dashboard
Recommended dashboard cards:
- total active boxes
- total storage used
- expired boxes waiting cleanup
- boxes created today / last 24 hours
- uploads completed today / last 24 hours
- failed or incomplete uploads
- active users
- disabled users
- API keys active
- admin sessions active
- thumbnail queue / worker state
- current global limits
- guest uploads status
- ZIP downloads status
- one-time downloads status
Recommended dashboard sections:
- "Needs attention"
- expired boxes
- failed uploads
- one-time boxes stuck incomplete
- high storage usage
- disabled API setting while API key UI is visible
- "Recent boxes"
- latest boxes with flags and size
- "Recent admin activity"
- user created, role changed, setting changed, API key revoked
- "System"
- data directory
- BadgerDB status
- uploads directory size
- thumbnail worker timing
- config source summary
UX idea:
- dashboard should answer "is WarpBox healthy?", "what changed recently?", and "what should I do next?" in one glance.
## Proposed Account Management
Recommended account list:
- search by username/email
- filter by status, role/group, plan, API key presence
- columns:
- user
- email
- status
- roles/groups
- plan/limits
- API keys
- created
- last login / last API use
- storage used
- boxes created
Recommended account detail page:
- profile
- status controls
- password reset / force password change
- roles/groups assignment
- plan/limits assignment
- effective permissions preview
- API keys tab
- recent activity tab
- boxes owned by user
Recommended safe actions:
- disable user
- revoke all sessions
- revoke all API keys
- reset password
- assign role/group
- assign plan
- archive user
Recommended UX details:
- show effective permission summary before save
- warn when removing own admin access
- require confirmation for disabling final admin
- prevent accidental lockout at backend level
- show inherited vs direct settings clearly
## Proposed API Key System
Data model idea:
- API key id
- user id
- name/label
- hashed secret
- secret prefix for display
- scopes
- created at
- expires at
- last used at
- revoked at
- created by
- optional allowed IP/CIDR list
Security rules:
- show raw key only once on creation
- store only hash server-side
- allow revoke, rotate, rename
- support expiry dates
- log last-used timestamp
- rate limit failed key attempts
- avoid putting API keys in URLs
User-facing API key page:
- create key
- name key
- choose expiry
- view active/revoked keys
- revoke key
- copy cURL example
- see last used time
Admin-facing API key controls:
- view user key count and last use
- revoke a user key
- revoke all keys for disabled user
- optionally create key on behalf of user
- audit key creation/revocation
Permission behavior:
- bearer key resolves to user
- user roles/groups/plans determine upload policy
- key scopes can further restrict user permission but not exceed it
- API key can enable higher quota only if assigned user's plan allows it
Initial scopes:
- `box:create`
- `box:upload`
- `box:read`
- `box:download`
- `box:delete-own`
## Proposed Role/Group System
Replace tags with explicit authorization objects.
Recommended models:
- Role: permission bundle, e.g. `admin`, `operator`, `uploader`, `viewer`
- Group: collection of users with assigned roles and optional plan
- Plan: quota/limit policy, e.g. `guest`, `standard`, `trusted`, `unlimited`
Roles should answer:
- what can this user do?
Plans should answer:
- how much can this user use?
Groups should answer:
- who receives these defaults together?
Recommended permissions:
- admin.access
- admin.dashboard.view
- admin.users.view
- admin.users.manage
- admin.roles.manage
- admin.settings.view
- admin.settings.manage
- admin.boxes.view
- admin.boxes.manage
- boxes.create
- boxes.download.zip
- boxes.download.one_time
- boxes.password.set
- boxes.renew
- api_keys.manage_own
- api_keys.manage_any
Recommended limit fields:
- max file size
- max box size
- max boxes per day
- max storage active at once
- max expiry
- allowed expiry choices
- max API keys
- API key max TTL
- guest upload allowed
- ZIP allowed
- one-time allowed
- renew allowed
Recommended resolver:
- start with system defaults
- apply assigned plan quotas
- apply group plan when user has no direct plan
- apply direct user overrides last
- roles grant permissions
- scopes restrict API key actions
- hard global limits cap everything
Migration strategy:
- create role equivalents from current tag admin booleans
- create plan equivalents from current tag upload limits
- assign users based on existing `TagIDs`
- keep read-only legacy tag view during migration
- remove tag creation from final UI
## Extra Feature Ideas
Storage and cleanup:
- expired box cleanup page
- bulk delete expired boxes
- storage by age chart
- largest boxes list
- orphaned manifest/file scanner
- thumbnail cleanup/rebuild tools
Security:
- audit log
- active admin sessions page
- revoke sessions
- failed login tracking
- optional two-factor auth for admins
- final-admin protection
- configurable password policy
Operations:
- system health page
- config export
- backup/restore notes for data directory and BadgerDB
- maintenance mode
- manual thumbnail worker run
- background job status
User experience:
- account self-service page
- personal upload history
- personal quota meter
- personal API keys
- upload presets
- saved retention preferences
Admin UX:
- global search
- command palette
- filters saved per admin
- CSV export for users/boxes
- inline detail drawer for boxes/users
- change preview before saving roles/plans
Product:
- named boxes supported server-side
- custom slug support server-side
- private/listed boxes if public listing is added
- max view count server-side
- ownership model: boxes created by user/API key belong to that user
- public share page controls based on owner plan
## Suggested Implementation Phases
Phase 1: make current admin useful
- add real dashboard statistics
- add search/filter/pagination to boxes and users
- expose effective permissions on user rows/details
- add user edit form
- add storage cleanup actions
Phase 2: implement API keys
- add API key model in BadgerDB
- add create/revoke/list routes
- hash keys server-side
- validate bearer keys on API endpoints
- connect key to user permissions
- add self-service API key UI
Phase 3: replace tags
- add roles/plans/groups models
- add resolver
- add migration from tags
- update user management UI
- deprecate tag creation
Phase 4: polish into full admin solution
- audit log
- account detail pages
- system health
- advanced cleanup
- activity timeline
- safer setting editor
## Product Principle For The Overhaul
Keep WarpBox small, local, and understandable. The admin area should not become enterprise software cosplay. It should give the operator sharp tools:
- see what is happening
- fix common problems
- manage people safely
- give trusted users more power
- keep storage under control
- make permission decisions obvious
Best version: a retro control panel that behaves like a modern, careful admin console.

19
check.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [ -n "${GO_BIN:-}" ]; then
go_bin="$GO_BIN"
elif command -v go >/dev/null 2>&1; then
go_bin="$(command -v go)"
elif [ -x /home/linuxbrew/.linuxbrew/bin/go ]; then
go_bin=/home/linuxbrew/.linuxbrew/bin/go
else
echo "go not found. Set GO_BIN=/path/to/go or install Go." >&2
exit 127
fi
"$go_bin" fmt ./...
"$go_bin" vet ./...
"$go_bin" test ./... "$@"

222
lib/boxstore/files.go Normal file
View File

@@ -0,0 +1,222 @@
package boxstore
import (
"fmt"
"io"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
return DecorateFiles(boxID, manifest.Files), nil
}
return listCompletedFilesFromDisk(boxID)
}
func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return models.BoxFile{}, err
}
if IsExpired(manifest) {
return models.BoxFile{}, fmt.Errorf("Box expired")
}
fileIndex := -1
for index, manifestFile := range manifest.Files {
if manifestFile.ID == fileID {
fileIndex = index
break
}
}
if fileIndex < 0 {
return models.BoxFile{}, fmt.Errorf("File not found")
}
filename := manifest.Files[fileIndex].Name
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
}
destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
manifest.Files[fileIndex].Status = models.FileStatusFailed
startRetentionIfTerminalUnlocked(&manifest)
writeManifestUnlocked(boxID, manifest)
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
}
manifest.Files[fileIndex].Size = file.Size
manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename)
manifest.Files[fileIndex].Status = models.FileStatusReady
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return models.BoxFile{}, err
}
return DecorateFile(boxID, manifest.Files[fileIndex]), nil
}
func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error) {
filename, ok := helpers.SafeFilename(file.Filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
boxPath := BoxPath(boxID)
if err := os.MkdirAll(boxPath, 0755); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
}
filename = helpers.UniqueFilename(boxPath, filename)
destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
}
return DecorateFile(boxID, models.BoxFile{
ID: filename,
Name: filename,
Size: file.Size,
MimeType: helpers.MimeTypeForFile(destination, filename),
Status: models.FileStatusReady,
}), nil
}
func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
if file.MimeType == "" {
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
}
}
if file.SizeLabel == "" {
file.SizeLabel = helpers.FormatBytes(file.Size)
}
file.IconPath = IconForMimeType(file.MimeType, file.Name)
if file.ThumbnailPath != nil {
file.ThumbnailURL = *file.ThumbnailPath
}
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
file.IsComplete = file.Status == models.FileStatusReady
switch file.Status {
case models.FileStatusReady:
file.StatusLabel = "Ready"
file.Title = "Download " + file.Name
case models.FileStatusFailed:
file.StatusLabel = "Failed"
file.Title = "Failed to upload"
case models.FileStatusWork:
file.StatusLabel = "Loading"
file.Title = "Loading"
default:
file.Status = models.FileStatusWait
file.StatusLabel = "Waiting"
file.Title = "Loading"
}
return file
}
func DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
decorated := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decorated = append(decorated, DecorateFile(boxID, file))
}
return decorated
}
func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
entries, err := os.ReadDir(BoxPath(boxID))
if err != nil {
return nil, err
}
files := make([]models.BoxFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || entry.Name() == manifestFile || entry.Type()&os.ModeSymlink != 0 {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
name := entry.Name()
files = append(files, DecorateFile(boxID, models.BoxFile{
ID: name,
Name: name,
Size: info.Size(),
MimeType: helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), name), name),
Status: models.FileStatusReady,
}))
}
return files, nil
}
func saveMultipartFile(file *multipart.FileHeader, destination string) error {
source, err := file.Open()
if err != nil {
return err
}
defer source.Close()
target, tempPath, err := createTempSibling(destination)
if err != nil {
return err
}
committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if _, err := io.Copy(target, source); err != nil {
return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, destination); err != nil {
return err
}
committed = true
return nil
}
func createTempSibling(destination string) (*os.File, string, error) {
directory := filepath.Dir(destination)
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, "", err
}
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
if err != nil {
return nil, "", err
}
return target, target.Name(), nil
}

33
lib/boxstore/icons.go Normal file
View File

@@ -0,0 +1,33 @@
package boxstore
import (
"path/filepath"
"strings"
)
func IconForMimeType(mimeType string, filename string) string {
extension := strings.ToLower(filepath.Ext(filename))
switch {
case extension == ".exe":
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
case strings.HasPrefix(mimeType, "image/"):
return "/static/img/sprites/bitmap.png"
case strings.HasPrefix(mimeType, "video/"):
return "/static/img/icons/netshow_notransm-1.png"
case strings.HasPrefix(mimeType, "audio/"):
return "/static/img/icons/netshow_notransm-1.png"
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
return "/static/img/sprites/notepad_file-1.png"
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
return "/static/img/sprites/font.png"
case extension == ".pdf":
return "/static/img/sprites/journal.png"
case extension == ".html" || extension == ".css" || extension == ".js":
return "/static/img/sprites/frame_web-0.png"
default:
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
}
}

220
lib/boxstore/manifest.go Normal file
View File

@@ -0,0 +1,220 @@
package boxstore
import (
"encoding/json"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
var manifestMu sync.Mutex
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
retention := normalizeRetentionOption(request.RetentionKey)
usedNames := make(map[string]int, len(request.Files))
files := make([]models.BoxFile, 0, len(request.Files))
for _, fileRequest := range request.Files {
filename, ok := helpers.SafeFilename(fileRequest.Name)
if !ok {
return nil, fmt.Errorf("Invalid filename")
}
filename = helpers.UniqueNameInBatch(filename, usedNames)
fileID, err := helpers.RandomHexID(8)
if err != nil {
return nil, fmt.Errorf("Could not create file id")
}
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
if mimeType == "" {
mimeType = "application/octet-stream"
}
files = append(files, models.BoxFile{
ID: fileID,
Name: filename,
Size: fileRequest.Size,
MimeType: mimeType,
Status: models.FileStatusWait,
})
}
now := time.Now().UTC()
disableZip := false
if request.AllowZip != nil {
disableZip = !*request.AllowZip
}
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
if oneTimeDownload {
disableZip = false
}
manifest := models.BoxManifest{
Files: files,
CreatedAt: now,
RetentionKey: retention.Key,
RetentionLabel: retention.Label,
RetentionSecs: retention.Seconds,
DisableZip: disableZip,
OneTimeDownload: oneTimeDownload,
}
if password := strings.TrimSpace(request.Password); password != "" {
authToken, err := helpers.RandomHexID(16)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
manifest.PasswordHash = string(passwordHash)
manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = authToken
}
if err := WriteManifest(boxID, manifest); err != nil {
return nil, err
}
decoratedFiles := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file))
}
return decoratedFiles, nil
}
func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) {
if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed {
return models.BoxFile{}, fmt.Errorf("Invalid file status")
}
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return models.BoxFile{}, err
}
for index, file := range manifest.Files {
if file.ID != fileID {
continue
}
manifest.Files[index].Status = status
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return models.BoxFile{}, err
}
return DecorateFile(boxID, manifest.Files[index]), nil
}
return models.BoxFile{}, fmt.Errorf("File not found")
}
func ReadManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
return readManifestUnlocked(boxID)
}
func WriteManifest(boxID string, manifest models.BoxManifest) error {
manifestMu.Lock()
defer manifestMu.Unlock()
return writeManifestUnlocked(boxID, manifest)
}
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
return manifest, nil
}
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func reconcileManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
changed := false
for index, file := range manifest.Files {
path, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
if file.Status == models.FileStatusReady && file.Size == info.Size() {
continue
}
// The manifest is the UI source of truth, but disk wins when an upload
// was saved and the final status write/response was interrupted.
manifest.Files[index].Size = info.Size()
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
manifest.Files[index].Status = models.FileStatusReady
changed = true
}
if changed {
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return manifest, err
}
}
return manifest, nil
}
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
var manifest models.BoxManifest
data, err := os.ReadFile(ManifestPath(boxID))
if err != nil {
return manifest, err
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, err
}
return manifest, nil
}
// Manifest writes are serialized because the browser can upload several files
// concurrently into the same box. Without this lock, status updates can race.
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(ManifestPath(boxID), data, 0644)
}

79
lib/boxstore/paths.go Normal file
View File

@@ -0,0 +1,79 @@
package boxstore
import (
"fmt"
"os"
"path/filepath"
"warpbox/lib/helpers"
)
const manifestFile = ".warpbox.json"
var uploadRoot = filepath.Join("data", "uploads")
func NewBoxID() (string, error) {
return helpers.RandomHexID(16)
}
func ValidBoxID(boxID string) bool {
return helpers.ValidLowerHexID(boxID, 32)
}
func SetUploadRoot(path string) {
if path == "" {
return
}
uploadRoot = filepath.Clean(path)
}
func UploadRoot() string {
return uploadRoot
}
func BoxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID)
}
func safeBoxPath(boxID string) (string, bool) {
if !ValidBoxID(boxID) {
return "", false
}
return helpers.SafeChildPath(uploadRoot, boxID)
}
func ManifestPath(boxID string) string {
return filepath.Join(BoxPath(boxID), manifestFile)
}
func SafeBoxFilePath(boxID string, filename string) (string, bool) {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return "", false
}
return helpers.SafeChildPath(boxPath, filename)
}
func IsSafeRegularBoxFile(boxID string, filename string) bool {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return false
}
return ensureRegularFile(path) == nil
}
func DeleteBox(boxID string) error {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return fmt.Errorf("Invalid box id")
}
return os.RemoveAll(boxPath)
}
func ensureRegularFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("Invalid file")
}
return nil
}

74
lib/boxstore/retention.go Normal file
View File

@@ -0,0 +1,74 @@
package boxstore
import (
"time"
"warpbox/lib/models"
)
const OneTimeDownloadRetentionKey = "one-time"
var oneTimeDownloadExpiry int64
var retentionOptions = []models.RetentionOption{
{Key: "10s", Label: "10 seconds", Seconds: 10},
{Key: "10m", Label: "10 minutes", Seconds: 10 * 60},
{Key: "1h", Label: "1 hour", Seconds: 60 * 60},
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
}
func RetentionOptions() []models.RetentionOption {
options := make([]models.RetentionOption, len(retentionOptions))
copy(options, retentionOptions)
return options
}
func DefaultRetentionOption() models.RetentionOption {
return retentionOptions[0]
}
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func normalizeRetentionOption(key string) models.RetentionOption {
for _, option := range retentionOptions {
if option.Key == key {
return option
}
}
return DefaultRetentionOption()
}
func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
return
}
seconds := manifest.RetentionSecs
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if seconds <= 0 {
return
}
for _, file := range manifest.Files {
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
return
}
}
// Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
}

51
lib/boxstore/security.go Normal file
View File

@@ -0,0 +1,51 @@
package boxstore
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/models"
)
func IsExpired(manifest models.BoxManifest) bool {
return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt)
}
func IsPasswordProtected(manifest models.BoxManifest) bool {
return manifest.PasswordHash != "" && manifest.AuthToken != ""
}
func VerifyPassword(manifest models.BoxManifest, password string) bool {
if !IsPasswordProtected(manifest) {
return true
}
expected := manifest.PasswordHash
if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
}
actual := legacyPasswordHash(manifest.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func VerifyAuthToken(manifest models.BoxManifest, token string) bool {
if !IsPasswordProtected(manifest) {
return true
}
if token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1
}
func legacyPasswordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}

View File

@@ -1,759 +0,0 @@
package boxstore
import (
"archive/zip"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
const (
manifestFile = ".warpbox.json"
OneTimeDownloadRetentionKey = "one-time"
)
var (
uploadRoot = filepath.Join("data", "uploads")
oneTimeDownloadExpiry int64
manifestMu sync.Mutex
)
var retentionOptions = []models.RetentionOption{
{Key: "10s", Label: "10 seconds", Seconds: 10},
{Key: "10m", Label: "10 minutes", Seconds: 10 * 60},
{Key: "1h", Label: "1 hour", Seconds: 60 * 60},
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
}
func NewBoxID() (string, error) {
return helpers.RandomHexID(16)
}
func ValidBoxID(boxID string) bool {
return helpers.ValidLowerHexID(boxID, 32)
}
func RetentionOptions() []models.RetentionOption {
options := make([]models.RetentionOption, len(retentionOptions))
copy(options, retentionOptions)
return options
}
func DefaultRetentionOption() models.RetentionOption {
return retentionOptions[0]
}
func SetUploadRoot(path string) {
if path == "" {
return
}
uploadRoot = filepath.Clean(path)
}
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func UploadRoot() string {
return uploadRoot
}
func BoxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID)
}
func safeBoxPath(boxID string) (string, bool) {
if !ValidBoxID(boxID) {
return "", false
}
return helpers.SafeChildPath(uploadRoot, boxID)
}
func ManifestPath(boxID string) string {
return filepath.Join(BoxPath(boxID), manifestFile)
}
func SafeBoxFilePath(boxID string, filename string) (string, bool) {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return "", false
}
return helpers.SafeChildPath(boxPath, filename)
}
func IsSafeRegularBoxFile(boxID string, filename string) bool {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return false
}
return ensureRegularFile(path) == nil
}
func DeleteBox(boxID string) error {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return fmt.Errorf("Invalid box id")
}
return os.RemoveAll(boxPath)
}
func ListBoxSummaries() ([]models.BoxSummary, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
summaries := make([]models.BoxSummary, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
continue
}
summary, err := BoxSummary(entry.Name())
if err != nil {
continue
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i int, j int) bool {
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
})
return summaries, nil
}
func BoxSummary(boxID string) (models.BoxSummary, error) {
files, err := ListFiles(boxID)
if err != nil {
return models.BoxSummary{}, err
}
var manifest models.BoxManifest
hasManifest := false
if readManifest, err := ReadManifest(boxID); err == nil {
manifest = readManifest
hasManifest = true
}
totalSize := int64(0)
for _, file := range files {
totalSize += file.Size
}
summary := models.BoxSummary{
ID: boxID,
FileCount: len(files),
TotalSize: totalSize,
TotalSizeLabel: helpers.FormatBytes(totalSize),
}
if hasManifest {
summary.CreatedAt = manifest.CreatedAt
summary.ExpiresAt = manifest.ExpiresAt
summary.Expired = IsExpired(manifest)
summary.OneTimeDownload = manifest.OneTimeDownload
summary.PasswordProtected = IsPasswordProtected(manifest)
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
summary.CreatedAt = info.ModTime().UTC()
}
return summary, nil
}
func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
return DecorateFiles(boxID, manifest.Files), nil
}
return listCompletedFilesFromDisk(boxID)
}
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
retention := normalizeRetentionOption(request.RetentionKey)
usedNames := make(map[string]int, len(request.Files))
files := make([]models.BoxFile, 0, len(request.Files))
for _, fileRequest := range request.Files {
filename, ok := helpers.SafeFilename(fileRequest.Name)
if !ok {
return nil, fmt.Errorf("Invalid filename")
}
filename = helpers.UniqueNameInBatch(filename, usedNames)
fileID, err := helpers.RandomHexID(8)
if err != nil {
return nil, fmt.Errorf("Could not create file id")
}
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
if mimeType == "" {
mimeType = "application/octet-stream"
}
files = append(files, models.BoxFile{
ID: fileID,
Name: filename,
Size: fileRequest.Size,
MimeType: mimeType,
Status: models.FileStatusWait,
})
}
now := time.Now().UTC()
disableZip := false
if request.AllowZip != nil {
disableZip = !*request.AllowZip
}
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
if oneTimeDownload {
disableZip = false
}
manifest := models.BoxManifest{
Files: files,
CreatedAt: now,
RetentionKey: retention.Key,
RetentionLabel: retention.Label,
RetentionSecs: retention.Seconds,
DisableZip: disableZip,
OneTimeDownload: oneTimeDownload,
}
if password := strings.TrimSpace(request.Password); password != "" {
authToken, err := helpers.RandomHexID(16)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
manifest.PasswordHash = string(passwordHash)
manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = authToken
}
if err := WriteManifest(boxID, manifest); err != nil {
return nil, err
}
decoratedFiles := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file))
}
return decoratedFiles, nil
}
func IsExpired(manifest models.BoxManifest) bool {
return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt)
}
func IsPasswordProtected(manifest models.BoxManifest) bool {
return manifest.PasswordHash != "" && manifest.AuthToken != ""
}
func VerifyPassword(manifest models.BoxManifest, password string) bool {
if !IsPasswordProtected(manifest) {
return true
}
expected := manifest.PasswordHash
if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
}
actual := legacyPasswordHash(manifest.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func VerifyAuthToken(manifest models.BoxManifest, token string) bool {
if !IsPasswordProtected(manifest) {
return true
}
if token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1
}
func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) {
if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed {
return models.BoxFile{}, fmt.Errorf("Invalid file status")
}
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return models.BoxFile{}, err
}
for index, file := range manifest.Files {
if file.ID != fileID {
continue
}
manifest.Files[index].Status = status
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return models.BoxFile{}, err
}
return DecorateFile(boxID, manifest.Files[index]), nil
}
return models.BoxFile{}, fmt.Errorf("File not found")
}
func ReadManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
return readManifestUnlocked(boxID)
}
func WriteManifest(boxID string, manifest models.BoxManifest) error {
manifestMu.Lock()
defer manifestMu.Unlock()
return writeManifestUnlocked(boxID, manifest)
}
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
return manifest, nil
}
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return fmt.Errorf("Invalid file")
}
if err := ensureRegularFile(path); err != nil {
return err
}
zipName, ok := safeZipEntryName(filename)
if !ok {
return fmt.Errorf("Invalid zip entry")
}
source, err := os.Open(path)
if err != nil {
return err
}
defer source.Close()
destination, err := zipWriter.Create(zipName)
if err != nil {
return err
}
_, err = io.Copy(destination, source)
return err
}
func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return models.BoxFile{}, err
}
if IsExpired(manifest) {
return models.BoxFile{}, fmt.Errorf("Box expired")
}
fileIndex := -1
for index, manifestFile := range manifest.Files {
if manifestFile.ID == fileID {
fileIndex = index
break
}
}
if fileIndex < 0 {
return models.BoxFile{}, fmt.Errorf("File not found")
}
filename := manifest.Files[fileIndex].Name
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
}
destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
manifest.Files[fileIndex].Status = models.FileStatusFailed
startRetentionIfTerminalUnlocked(&manifest)
writeManifestUnlocked(boxID, manifest)
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
}
manifest.Files[fileIndex].Size = file.Size
manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename)
manifest.Files[fileIndex].Status = models.FileStatusReady
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return models.BoxFile{}, err
}
return DecorateFile(boxID, manifest.Files[fileIndex]), nil
}
func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error) {
filename, ok := helpers.SafeFilename(file.Filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
boxPath := BoxPath(boxID)
if err := os.MkdirAll(boxPath, 0755); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
}
filename = helpers.UniqueFilename(boxPath, filename)
destination, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
}
return DecorateFile(boxID, models.BoxFile{
ID: filename,
Name: filename,
Size: file.Size,
MimeType: helpers.MimeTypeForFile(destination, filename),
Status: models.FileStatusReady,
}), nil
}
func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
if file.MimeType == "" {
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
}
}
if file.SizeLabel == "" {
file.SizeLabel = helpers.FormatBytes(file.Size)
}
file.IconPath = IconForMimeType(file.MimeType, file.Name)
if file.ThumbnailPath != nil {
file.ThumbnailURL = *file.ThumbnailPath
}
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
file.IsComplete = file.Status == models.FileStatusReady
switch file.Status {
case models.FileStatusReady:
file.StatusLabel = "Ready"
file.Title = "Download " + file.Name
case models.FileStatusFailed:
file.StatusLabel = "Failed"
file.Title = "Failed to upload"
case models.FileStatusWork:
file.StatusLabel = "Loading"
file.Title = "Loading"
default:
file.Status = models.FileStatusWait
file.StatusLabel = "Waiting"
file.Title = "Loading"
}
return file
}
func DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
decorated := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decorated = append(decorated, DecorateFile(boxID, file))
}
return decorated
}
func IconForMimeType(mimeType string, filename string) string {
extension := strings.ToLower(filepath.Ext(filename))
switch {
case extension == ".exe":
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
case strings.HasPrefix(mimeType, "image/"):
return "/static/img/sprites/bitmap.png"
case strings.HasPrefix(mimeType, "video/"):
return "/static/img/icons/netshow_notransm-1.png"
case strings.HasPrefix(mimeType, "audio/"):
return "/static/img/icons/netshow_notransm-1.png"
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
return "/static/img/sprites/notepad_file-1.png"
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
return "/static/img/sprites/font.png"
case extension == ".pdf":
return "/static/img/sprites/journal.png"
case extension == ".html" || extension == ".css" || extension == ".js":
return "/static/img/sprites/frame_web-0.png"
default:
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
}
}
func reconcileManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
changed := false
for index, file := range manifest.Files {
path, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
if file.Status == models.FileStatusReady && file.Size == info.Size() {
continue
}
// The manifest is the UI source of truth, but disk wins when an upload
// was saved and the final status write/response was interrupted.
manifest.Files[index].Size = info.Size()
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
manifest.Files[index].Status = models.FileStatusReady
changed = true
}
if changed {
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return manifest, err
}
}
return manifest, nil
}
func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
entries, err := os.ReadDir(BoxPath(boxID))
if err != nil {
return nil, err
}
files := make([]models.BoxFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || entry.Name() == manifestFile || entry.Type()&os.ModeSymlink != 0 {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
name := entry.Name()
files = append(files, DecorateFile(boxID, models.BoxFile{
ID: name,
Name: name,
Size: info.Size(),
MimeType: helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), name), name),
Status: models.FileStatusReady,
}))
}
return files, nil
}
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
var manifest models.BoxManifest
data, err := os.ReadFile(ManifestPath(boxID))
if err != nil {
return manifest, err
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, err
}
return manifest, nil
}
func normalizeRetentionOption(key string) models.RetentionOption {
for _, option := range retentionOptions {
if option.Key == key {
return option
}
}
return DefaultRetentionOption()
}
func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
return
}
seconds := manifest.RetentionSecs
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if seconds <= 0 {
return
}
for _, file := range manifest.Files {
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
return
}
}
// Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
}
func legacyPasswordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
// Manifest writes are serialized because the browser can upload several files
// concurrently into the same box. Without this lock, status updates can race.
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(ManifestPath(boxID), data, 0644)
}
func saveMultipartFile(file *multipart.FileHeader, destination string) error {
source, err := file.Open()
if err != nil {
return err
}
defer source.Close()
target, tempPath, err := createTempSibling(destination)
if err != nil {
return err
}
committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if _, err := io.Copy(target, source); err != nil {
return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, destination); err != nil {
return err
}
committed = true
return nil
}
func createTempSibling(destination string) (*os.File, string, error) {
directory := filepath.Dir(destination)
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, "", err
}
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
if err != nil {
return nil, "", err
}
return target, target.Name(), nil
}
func safeZipEntryName(filename string) (string, bool) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.IsAbs(filename) {
return "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filename))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
return "", false
}
return cleaned, true
}
func ensureRegularFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("Invalid file")
}
return nil
}

74
lib/boxstore/summary.go Normal file
View File

@@ -0,0 +1,74 @@
package boxstore
import (
"os"
"sort"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func ListBoxSummaries() ([]models.BoxSummary, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
summaries := make([]models.BoxSummary, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
continue
}
summary, err := BoxSummary(entry.Name())
if err != nil {
continue
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i int, j int) bool {
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
})
return summaries, nil
}
func BoxSummary(boxID string) (models.BoxSummary, error) {
files, err := ListFiles(boxID)
if err != nil {
return models.BoxSummary{}, err
}
var manifest models.BoxManifest
hasManifest := false
if readManifest, err := ReadManifest(boxID); err == nil {
manifest = readManifest
hasManifest = true
}
totalSize := int64(0)
for _, file := range files {
totalSize += file.Size
}
summary := models.BoxSummary{
ID: boxID,
FileCount: len(files),
TotalSize: totalSize,
TotalSizeLabel: helpers.FormatBytes(totalSize),
}
if hasManifest {
summary.CreatedAt = manifest.CreatedAt
summary.ExpiresAt = manifest.ExpiresAt
summary.Expired = IsExpired(manifest)
summary.OneTimeDownload = manifest.OneTimeDownload
summary.PasswordProtected = IsPasswordProtected(manifest)
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
summary.CreatedAt = info.ModTime().UTC()
}
return summary, nil
}

50
lib/boxstore/zip.go Normal file
View File

@@ -0,0 +1,50 @@
package boxstore
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return fmt.Errorf("Invalid file")
}
if err := ensureRegularFile(path); err != nil {
return err
}
zipName, ok := safeZipEntryName(filename)
if !ok {
return fmt.Errorf("Invalid zip entry")
}
source, err := os.Open(path)
if err != nil {
return err
}
defer source.Close()
destination, err := zipWriter.Create(zipName)
if err != nil {
return err
}
_, err = io.Copy(destination, source)
return err
}
func safeZipEntryName(filename string) (string, bool) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.IsAbs(filename) {
return "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filename))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
return "", false
}
return cleaned, true
}

View File

@@ -1,578 +0,0 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
type Source string
const (
SourceDefault Source = "default"
SourceEnv Source = "environment"
SourceDB Source = "db override"
)
type AdminEnabledMode string
const (
AdminEnabledAuto AdminEnabledMode = "auto"
AdminEnabledTrue AdminEnabledMode = "true"
AdminEnabledFalse AdminEnabledMode = "false"
)
const (
SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
)
type SettingType string
const (
SettingTypeBool SettingType = "bool"
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
)
type SettingDefinition struct {
Key string
EnvName string
Label string
Type SettingType
Editable bool
HardLimit bool
Minimum int64
}
type SettingRow struct {
Definition SettingDefinition
Value string
Source Source
}
type Config struct {
DataDir string
UploadsDir string
DBDir string
AdminPassword string
AdminUsername string
AdminEmail string
AdminEnabled AdminEnabledMode
AdminCookieSecure bool
AllowAdminSettingsOverride bool
GuestUploadsEnabled bool
APIEnabled bool
ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64
OneTimeDownloadRetryOnFailure bool
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
sources map[string]Source
values map[string]string
}
var Definitions = []SettingDefinition{
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
}
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
}
cfg.captureDefaults()
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
}
cfg.AdminEnabled = mode
}
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
return nil, err
}
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
return nil, err
}
envBools := []struct {
key string
name string
target *bool
}{
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
return nil, err
}
}
envInt64s := []struct {
key string
name string
min int64
target *int64
}{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
sizeEnvVars := []struct {
key string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
envInts := []struct {
key string
name string
min int
target *int
}{
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
cfg.DataDir = filepath.Clean(cfg.DataDir)
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
cfg.DataDir = "data"
}
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
return cfg, nil
}
func (cfg *Config) EnsureDirectories() error {
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
}
return nil
}
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
if !cfg.AllowAdminSettingsOverride {
return nil
}
for key, value := range overrides {
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (cfg *Config) ApplyOverride(key string, value string) error {
def, ok := Definition(key)
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignBool(key, parsed, SourceDB)
case SettingTypeInt64:
parsed, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func (cfg *Config) SettingRows() []SettingRow {
rows := make([]SettingRow, 0, len(Definitions))
for _, def := range Definitions {
rows = append(rows, SettingRow{
Definition: def,
Value: cfg.values[def.Key],
Source: cfg.sourceFor(def.Key),
})
}
return rows
}
func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:
return false
case AdminEnabledTrue:
return hasAdminUser
default:
return hasAdminUser
}
}
func Definition(key string) (SettingDefinition, bool) {
for _, def := range Definitions {
if def.Key == key {
return def, true
}
}
return SettingDefinition{}, false
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {
if def.Editable && !def.HardLimit {
defs = append(defs, def)
}
}
return defs
}
func (cfg *Config) captureDefaults() {
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
}
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
raw := os.Getenv(name)
if raw == "" {
return nil
}
*target = raw
if key != "" {
cfg.setValue(key, raw, SourceEnv)
}
return nil
}
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseBool(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, formatBool(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt64(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
}
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
return nil
}
rawMB := strings.TrimSpace(os.Getenv(mbName))
if rawMB == "" {
return nil
}
parsedMB, err := parseInt64(rawMB, min)
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
return nil
}
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
cfg.GuestUploadsEnabled = value
case SettingAPIEnabled:
cfg.APIEnabled = value
case SettingZipDownloadsEnabled:
cfg.ZipDownloadsEnabled = value
case SettingOneTimeDownloadsEnabled:
cfg.OneTimeDownloadsEnabled = value
case SettingRenewOnAccessEnabled:
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
func (cfg *Config) assignInt64(key string, value int64, source Source) {
switch key {
case SettingDefaultGuestExpirySecs:
cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
func (cfg *Config) assignInt(key string, value int, source Source) {
switch key {
case SettingBoxPollIntervalMS:
cfg.BoxPollIntervalMS = value
case SettingThumbnailBatchSize:
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return
}
cfg.values[key] = value
cfg.sources[key] = source
}
func (cfg *Config) sourceFor(key string) Source {
source, ok := cfg.sources[key]
if !ok {
return SourceDefault
}
return source
}
func parseBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true, nil
case "0", "f", "false", "n", "no", "off":
return false, nil
default:
return false, fmt.Errorf("must be a boolean")
}
}
func parseInt64(value string, min int64) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %d", min)
}
return parsed, nil
}
func parseInt(value string, min int) (int, error) {
parsed64, err := parseInt64(value, int64(min))
if err != nil {
return 0, err
}
if parsed64 > int64(^uint(0)>>1) {
return 0, fmt.Errorf("is too large")
}
return int(parsed64), nil
}
func formatBool(value bool) string {
if value {
return "true"
}
return "false"
}

69
lib/config/definitions.go Normal file
View File

@@ -0,0 +1,69 @@
package config
var Definitions = []SettingDefinition{
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
}
func (cfg *Config) SettingRows() []SettingRow {
rows := make([]SettingRow, 0, len(Definitions))
for _, def := range Definitions {
rows = append(rows, SettingRow{
Definition: def,
Value: cfg.values[def.Key],
Source: cfg.sourceFor(def.Key),
})
}
return rows
}
func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:
return false
case AdminEnabledTrue:
return hasAdminUser
default:
return hasAdminUser
}
}
func Definition(key string) (SettingDefinition, bool) {
for _, def := range Definitions {
if def.Key == key {
return def, true
}
}
return SettingDefinition{}, false
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {
if def.Editable && !def.HardLimit {
defs = append(defs, def)
}
}
return defs
}

262
lib/config/load.go Normal file
View File

@@ -0,0 +1,262 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
}
// Config precedence: defaults -> env -> overrides.
// Overrides are applied after Load by the server once the metadata store opens.
cfg.captureDefaults()
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
}
cfg.AdminEnabled = mode
}
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
return nil, err
}
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
return nil, err
}
envBools := []struct {
key string
name string
target *bool
}{
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
return nil, err
}
}
envInt64s := []struct {
key string
name string
min int64
target *int64
}{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
sizeEnvVars := []struct {
key string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
envInts := []struct {
key string
name string
min int
target *int
}{
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
cfg.DataDir = filepath.Clean(cfg.DataDir)
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
cfg.DataDir = "data"
}
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
return cfg, nil
}
func (cfg *Config) EnsureDirectories() error {
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
}
return nil
}
func (cfg *Config) captureDefaults() {
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
}
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
raw := os.Getenv(name)
if raw == "" {
return nil
}
*target = raw
if key != "" {
cfg.setValue(key, raw, SourceEnv)
}
return nil
}
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseBool(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, formatBool(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt64(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
}
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
return nil
}
rawMB := strings.TrimSpace(os.Getenv(mbName))
if rawMB == "" {
return nil
}
parsedMB, err := parseInt64(rawMB, min)
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
return nil
}
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
}
return nil
}

100
lib/config/models.go Normal file
View File

@@ -0,0 +1,100 @@
package config
type Source string
const (
SourceDefault Source = "default"
SourceEnv Source = "environment"
SourceDB Source = "db override"
)
type AdminEnabledMode string
const (
AdminEnabledAuto AdminEnabledMode = "auto"
AdminEnabledTrue AdminEnabledMode = "true"
AdminEnabledFalse AdminEnabledMode = "false"
)
const (
SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
)
type SettingType string
const (
SettingTypeBool SettingType = "bool"
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
)
type SettingDefinition struct {
Key string
EnvName string
Label string
Type SettingType
Editable bool
HardLimit bool
Minimum int64
}
type SettingRow struct {
Definition SettingDefinition
Value string
Source Source
}
type Config struct {
DataDir string
UploadsDir string
DBDir string
AdminPassword string
AdminUsername string
AdminEmail string
AdminEnabled AdminEnabledMode
AdminCookieSecure bool
AllowAdminSettingsOverride bool
GuestUploadsEnabled bool
APIEnabled bool
ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64
OneTimeDownloadRetryOnFailure bool
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
sources map[string]Source
values map[string]string
}

115
lib/config/overrides.go Normal file
View File

@@ -0,0 +1,115 @@
package config
import (
"fmt"
"strconv"
)
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
if !cfg.AllowAdminSettingsOverride {
return nil
}
for key, value := range overrides {
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (cfg *Config) ApplyOverride(key string, value string) error {
def, ok := Definition(key)
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignBool(key, parsed, SourceDB)
case SettingTypeInt64:
parsed, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
cfg.GuestUploadsEnabled = value
case SettingAPIEnabled:
cfg.APIEnabled = value
case SettingZipDownloadsEnabled:
cfg.ZipDownloadsEnabled = value
case SettingOneTimeDownloadsEnabled:
cfg.OneTimeDownloadsEnabled = value
case SettingRenewOnAccessEnabled:
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
func (cfg *Config) assignInt64(key string, value int64, source Source) {
switch key {
case SettingDefaultGuestExpirySecs:
cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
func (cfg *Config) assignInt(key string, value int, source Source) {
switch key {
case SettingBoxPollIntervalMS:
cfg.BoxPollIntervalMS = value
case SettingThumbnailBatchSize:
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return
}
cfg.values[key] = value
cfg.sources[key] = source
}
func (cfg *Config) sourceFor(key string) Source {
source, ok := cfg.sources[key]
if !ok {
return SourceDefault
}
return source
}

47
lib/config/parse.go Normal file
View File

@@ -0,0 +1,47 @@
package config
import (
"fmt"
"strconv"
"strings"
)
func parseBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true, nil
case "0", "f", "false", "n", "no", "off":
return false, nil
default:
return false, fmt.Errorf("must be a boolean")
}
}
func parseInt64(value string, min int64) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %d", min)
}
return parsed, nil
}
func parseInt(value string, min int) (int, error) {
parsed64, err := parseInt64(value, int64(min))
if err != nil {
return 0, err
}
if parsed64 > int64(^uint(0)>>1) {
return 0, fmt.Errorf("is too large")
}
return int(parsed64), nil
}
func formatBool(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -1,608 +0,0 @@
package server
import (
"crypto/subtle"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
}
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost)
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
return
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
}
rows = append(rows, adminBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
PasswordProtected: summary.PasswordProtected,
})
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
})
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
app.renderAdminUsers(ctx, "User not found.")
return
}
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
return
}
}
user.Disabled = !user.Disabled
if err := app.store.UpdateUser(user); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
password := ctx.PostForm("password")
tagIDs := ctx.PostFormArray("tag_ids")
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
}
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
tagNames := make(map[string]string, len(tags))
for _, tag := range tags {
tagNames[tag.ID] = tag.Name
}
sort.Slice(users, func(i int, j int) bool {
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
})
currentID := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
}
}
rows := make([]adminUserRow, 0, len(users))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
}
}
rows = append(rows, adminUserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
})
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
})
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

192
lib/server/admin_auth.go Normal file
View File

@@ -0,0 +1,192 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

63
lib/server/admin_boxes.go Normal file
View File

@@ -0,0 +1,63 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
return
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
}
rows = append(rows, adminBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
PasswordProtected: summary.PasswordProtected,
})
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"AdminSection": "boxes",
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
})
}

View File

@@ -0,0 +1,14 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}

View File

@@ -0,0 +1,73 @@
package server
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

View File

@@ -0,0 +1,23 @@
package server
import "github.com/gin-gonic/gin"
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost)
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}

View File

@@ -0,0 +1,58 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"AdminSection": "settings",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
})
}

122
lib/server/admin_tags.go Normal file
View File

@@ -0,0 +1,122 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"AdminSection": "tags",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}

121
lib/server/admin_users.go Normal file
View File

@@ -0,0 +1,121 @@
package server
import (
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
app.renderAdminUsers(ctx, "User not found.")
return
}
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
return
}
}
user.Disabled = !user.Disabled
if err := app.store.UpdateUser(user); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
password := ctx.PostForm("password")
tagIDs := ctx.PostFormArray("tag_ids")
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
}
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
tagNames := make(map[string]string, len(tags))
for _, tag := range tags {
tagNames[tag.ID] = tag.Name
}
sort.Slice(users, func(i int, j int) bool {
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
})
currentID := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
}
}
rows := make([]adminUserRow, 0, len(users))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
}
}
rows = append(rows, adminUserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"AdminSection": "users",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
})
}

135
lib/server/box_auth.go Normal file
View File

@@ -0,0 +1,135 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
const boxAuthCookiePrefix = "warpbox_box_"
func handleBoxLogin(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
return
}
renderBoxLogin(ctx, boxID, "")
}
func handleBoxLoginPost(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
renderBoxLogin(ctx, boxID, "The password was not accepted.")
return
}
maxAge := 24 * 60 * 60
if !manifest.ExpiresAt.IsZero() {
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
if seconds > 0 {
maxAge = seconds
}
}
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return models.BoxManifest{}, false, true
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
if wantsHTML {
ctx.String(http.StatusGone, "Box expired")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
}
return manifest, true, false
}
if manifest.OneTimeDownload && manifest.Consumed {
if wantsHTML {
ctx.String(http.StatusGone, "Box already consumed")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
}
return manifest, true, false
}
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
if wantsHTML {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
} else {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
}
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
token, err := ctx.Cookie(boxAuthCookieName(boxID))
return err == nil && boxstore.VerifyAuthToken(manifest, token)
}
func boxAuthCookieName(boxID string) string {
return boxAuthCookiePrefix + boxID
}
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
"BoxID": boxID,
"BoxUser": "WarpBox\\" + boxID,
"ErrorMessage": errorMessage,
})
}

281
lib/server/downloads.go Normal file
View File

@@ -0,0 +1,281 @@
package server
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"sync"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
var oneTimeDownloadLocks sync.Map
func (app *App) handleDownloadBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
if !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if hasManifest && manifest.OneTimeDownload {
app.handleOneTimeDownloadBox(ctx, boxID)
return
}
if hasManifest && manifest.DisableZip {
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
lock := oneTimeDownloadLock(boxID)
lock.Lock()
defer lock.Unlock()
defer oneTimeDownloadLocks.Delete(boxID)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusGone, "Box already consumed")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !allFilesComplete(files) {
ctx.String(http.StatusConflict, "Box is not ready yet")
return
}
if app.config.OneTimeDownloadRetryOnFailure {
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
boxstore.DeleteBox(boxID)
return
}
boxstore.DeleteBox(boxID)
}
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
writeBoxZipHeaders(ctx, boxID)
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
ctx.Status(http.StatusInternalServerError)
return false
}
return true
}
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
return
}
tempPath := tempZip.Name()
defer os.Remove(tempPath)
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
return
}
if _, err := tempZip.Seek(0, 0); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
return
}
writeBoxZipHeaders(ctx, boxID)
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
tempZip.Close()
return
}
if err := tempZip.Close(); err != nil {
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
return
}
boxstore.DeleteBox(boxID)
}
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
ctx.Header("Content-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
}
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
zipWriter := zip.NewWriter(destination)
for _, file := range files {
if !file.IsComplete {
continue
}
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
return err
}
}
if err := zipWriter.Close(); err != nil {
return err
}
return nil
}
func oneTimeDownloadLock(boxID string) *sync.Mutex {
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func allFilesComplete(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if !file.IsComplete {
return false
}
}
return true
}
func manifestFilesReady(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if file.Status != models.FileStatusReady {
return false
}
}
return true
}
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
stripped := make([]models.BoxFile, 0, len(files))
for _, file := range files {
file.ThumbnailPath = nil
file.ThumbnailURL = ""
if file.ThumbnailStatus == "" {
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
}
stripped = append(stripped, file)
}
return stripped
}
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
return
}
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "File not found")
return
}
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
return
}
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "Thumbnail not found")
return
}
ctx.Header("Content-Type", "image/jpeg")
ctx.File(path)
}

View File

@@ -1,904 +0,0 @@
package server
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
const boxAuthCookiePrefix = "warpbox_box_"
var oneTimeDownloadLocks sync.Map
func formatBrowserTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func (app *App) handleShowBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
downloadAll := "/box/" + boxID + "/download"
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
ctx.HTML(http.StatusOK, "box.html", gin.H{
"BoxID": boxID,
"Files": files,
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
})
}
func handleBoxLogin(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
return
}
renderBoxLogin(ctx, boxID, "")
}
func handleBoxLoginPost(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
renderBoxLogin(ctx, boxID, "The password was not accepted.")
return
}
maxAge := 24 * 60 * 60
if !manifest.ExpiresAt.IsZero() {
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
if seconds > 0 {
maxAge = seconds
}
}
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func (app *App) handleBoxStatus(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok {
return
}
var files []models.BoxFile
if hasManifest && manifestFilesReady(manifest.Files) {
files = boxstore.DecorateFiles(boxID, manifest.Files)
} else {
var err error
files, err = boxstore.ListFiles(boxID)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
return
}
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
}
func (app *App) handleDownloadBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
if !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if hasManifest && manifest.OneTimeDownload {
app.handleOneTimeDownloadBox(ctx, boxID)
return
}
if hasManifest && manifest.DisableZip {
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
lock := oneTimeDownloadLock(boxID)
lock.Lock()
defer lock.Unlock()
defer oneTimeDownloadLocks.Delete(boxID)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusGone, "Box already consumed")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !allFilesComplete(files) {
ctx.String(http.StatusConflict, "Box is not ready yet")
return
}
if app.config.OneTimeDownloadRetryOnFailure {
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
boxstore.DeleteBox(boxID)
return
}
boxstore.DeleteBox(boxID)
}
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
writeBoxZipHeaders(ctx, boxID)
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
ctx.Status(http.StatusInternalServerError)
return false
}
return true
}
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
return
}
tempPath := tempZip.Name()
defer os.Remove(tempPath)
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
return
}
if _, err := tempZip.Seek(0, 0); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
return
}
writeBoxZipHeaders(ctx, boxID)
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
tempZip.Close()
return
}
if err := tempZip.Close(); err != nil {
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
return
}
boxstore.DeleteBox(boxID)
}
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
ctx.Header("Content-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
}
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
zipWriter := zip.NewWriter(destination)
for _, file := range files {
if !file.IsComplete {
continue
}
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
return err
}
}
if err := zipWriter.Close(); err != nil {
return err
}
return nil
}
func oneTimeDownloadLock(boxID string) *sync.Mutex {
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func allFilesComplete(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if !file.IsComplete {
return false
}
}
return true
}
func manifestFilesReady(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if file.Status != models.FileStatusReady {
return false
}
}
return true
}
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
stripped := make([]models.BoxFile, 0, len(files))
for _, file := range files {
file.ThumbnailPath = nil
file.ThumbnailURL = ""
if file.ThumbnailStatus == "" {
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
}
stripped = append(stripped, file)
}
return stripped
}
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
return
}
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "File not found")
return
}
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
return
}
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "Thumbnail not found")
return
}
ctx.Header("Content-Type", "image/jpeg")
ctx.File(path)
}
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
var request models.CreateBoxRequest
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
return
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
files, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
}
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
return
}
var request models.UpdateFileStatusRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
return
}
if request.Status == models.FileStatusReady {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
return
}
if err := app.rejectExpiredManifestBox(boxID); err != nil {
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
return
}
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
form, err := ctx.MultipartForm()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
return
}
files := form.File["files"]
if len(files) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
return
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
if retentionKey == "" {
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
}
allowZip := true
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
allowZip = false
}
request := models.CreateBoxRequest{
RetentionKey: retentionKey,
Password: ctx.PostForm("password"),
AllowZip: &allowZip,
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
}
for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
manifestFiles, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles := make([]models.BoxFile, 0, len(files))
for index, file := range files {
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
if err != nil {
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles = append(savedFiles, savedFile)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
}
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return models.BoxManifest{}, false, true
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
if wantsHTML {
ctx.String(http.StatusGone, "Box expired")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
}
return manifest, true, false
}
if manifest.OneTimeDownload && manifest.Consumed {
if wantsHTML {
ctx.String(http.StatusGone, "Box already consumed")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
}
return manifest, true, false
}
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
if wantsHTML {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
} else {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
}
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
token, err := ctx.Cookie(boxAuthCookieName(boxID))
return err == nil && boxstore.VerifyAuthToken(manifest, token)
}
func boxAuthCookieName(boxID string) string {
return boxAuthCookiePrefix + boxID
}
func (app *App) requireAPI(ctx *gin.Context) bool {
if app.config.APIEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
return false
}
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
if app.config.GuestUploadsEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
return false
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
if request == nil {
return nil
}
if !app.retentionAllowed(request.RetentionKey) {
return fmt.Errorf("Retention option is not allowed")
}
if !app.config.ZipDownloadsEnabled {
allowZip := false
request.AllowZip = &allowZip
}
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
return fmt.Errorf("One-time downloads are disabled")
}
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return nil
}
totalSize := size
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
totalSize := int64(0)
found := false
for _, file := range manifest.Files {
if file.ID == fileID {
totalSize += size
found = true
continue
}
totalSize += file.Size
}
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateFileSize(size int64) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
}
func (app *App) rejectExpiredManifestBox(boxID string) error {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return nil
}
if !boxstore.IsExpired(manifest) {
return nil
}
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes()
if limit <= 0 {
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
}
func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
limit = app.config.GlobalMaxFileSizeBytes
}
if limit <= 0 {
return 0
}
return limit + 10*1024*1024
}
func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
for _, option := range app.retentionOptions() {
if option.Key == key {
return true
}
}
return false
}
func (app *App) retentionOptions() []models.RetentionOption {
allOptions := boxstore.RetentionOptions()
options := make([]models.RetentionOption, 0, len(allOptions))
for _, option := range allOptions {
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
continue
}
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
continue
}
options = append(options, option)
}
if len(options) == 0 {
return allOptions[:1]
}
return options
}
func (app *App) defaultRetentionOption() models.RetentionOption {
options := app.retentionOptions()
for _, option := range options {
if option.Seconds == app.config.DefaultGuestExpirySeconds {
return option
}
}
return options[0]
}
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
"BoxID": boxID,
"BoxUser": "WarpBox\\" + boxID,
"ErrorMessage": errorMessage,
})
}

100
lib/server/pages.go Normal file
View File

@@ -0,0 +1,100 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func formatBrowserTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func (app *App) handleShowBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
downloadAll := "/box/" + boxID + "/download"
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
ctx.HTML(http.StatusOK, "box.html", gin.H{
"BoxID": boxID,
"Files": files,
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
})
}
func (app *App) handleBoxStatus(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok {
return
}
var files []models.BoxFile
if hasManifest && manifestFilesReady(manifest.Files) {
files = boxstore.DecorateFiles(boxID, manifest.Files)
} else {
var err error
files, err = boxstore.ListFiles(boxID)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
return
}
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
}

49
lib/server/retention.go Normal file
View File

@@ -0,0 +1,49 @@
package server
import (
"strings"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
for _, option := range app.retentionOptions() {
if option.Key == key {
return true
}
}
return false
}
func (app *App) retentionOptions() []models.RetentionOption {
allOptions := boxstore.RetentionOptions()
options := make([]models.RetentionOption, 0, len(allOptions))
for _, option := range allOptions {
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
continue
}
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
continue
}
options = append(options, option)
}
if len(options) == 0 {
return allOptions[:1]
}
return options
}
func (app *App) defaultRetentionOption() models.RetentionOption {
options := app.retentionOptions()
for _, option := range options {
if option.Seconds == app.config.DefaultGuestExpirySeconds {
return option
}
}
return options[0]
}

236
lib/server/uploads.go Normal file
View File

@@ -0,0 +1,236 @@
package server
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
var request models.CreateBoxRequest
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
return
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
files, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
}
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
return
}
var request models.UpdateFileStatusRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
return
}
if request.Status == models.FileStatusReady {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
return
}
if err := app.rejectExpiredManifestBox(boxID); err != nil {
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
return
}
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
form, err := ctx.MultipartForm()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
return
}
files := form.File["files"]
if len(files) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
return
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
if retentionKey == "" {
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
}
allowZip := true
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
allowZip = false
}
request := models.CreateBoxRequest{
RetentionKey: retentionKey,
Password: ctx.PostForm("password"),
AllowZip: &allowZip,
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
}
for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
manifestFiles, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles := make([]models.BoxFile, 0, len(files))
for index, file := range files {
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
if err != nil {
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles = append(savedFiles, savedFile)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
}

155
lib/server/validation.go Normal file
View File

@@ -0,0 +1,155 @@
package server
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func (app *App) requireAPI(ctx *gin.Context) bool {
if app.config.APIEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
return false
}
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
if app.config.GuestUploadsEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
return false
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
if request == nil {
return nil
}
if !app.retentionAllowed(request.RetentionKey) {
return fmt.Errorf("Retention option is not allowed")
}
if !app.config.ZipDownloadsEnabled {
allowZip := false
request.AllowZip = &allowZip
}
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
return fmt.Errorf("One-time downloads are disabled")
}
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return nil
}
totalSize := size
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
totalSize := int64(0)
found := false
for _, file := range manifest.Files {
if file.ID == fileID {
totalSize += size
found = true
continue
}
totalSize += file.Size
}
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateFileSize(size int64) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
}
func (app *App) rejectExpiredManifestBox(boxID string) error {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return nil
}
if !boxstore.IsExpired(manifest) {
return nil
}
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes()
if limit <= 0 {
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
}
func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
limit = app.config.GlobalMaxFileSizeBytes
}
if limit <= 0 {
return 0
}
return limit + 10*1024*1024
}

View File

@@ -0,0 +1,117 @@
.menu-bar {
position: relative;
display: flex;
align-items: center;
gap: 2px;
height: 24px;
padding: 1px 6px;
font-size: 13px;
line-height: 13px;
z-index: 5;
}
.menu-item {
position: relative;
}
.menu-button {
height: 20px;
min-width: 54px;
padding: 0 8px;
color: #000000;
background: transparent;
border: 1px solid transparent;
font-family: inherit;
font-size: 13px;
text-align: left;
}
.menu-button:hover,
.menu-button:focus-visible {
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
outline: none;
}
.menu-popup {
position: absolute;
top: 22px;
left: 0;
min-width: 198px;
padding: 2px;
display: none;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: 3px 3px 0 rgba(0,0,0,.35);
z-index: 20;
}
.menu-item.is-open .menu-popup {
display: block;
}
.menu-action {
width: 100%;
min-height: 22px;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 2px 6px;
color: #000000;
background: transparent;
border: 0;
font-family: inherit;
font-size: 12px;
text-align: left;
}
.menu-action[aria-disabled="true"] {
color: #808080;
text-shadow: 1px 1px 0 #ffffff;
}
.menu-action[aria-disabled="true"] img {
opacity: .55;
filter: grayscale(1);
}
.menu-action[aria-disabled="true"]:hover,
.menu-action[aria-disabled="true"]:focus-visible {
color: #808080;
background: transparent;
}
.menu-action img {
width: 16px;
height: 16px;
object-fit: contain;
image-rendering: pixelated;
}
.menu-action:hover,
.menu-action:focus-visible {
color: #ffffff;
background: #000078;
outline: none;
}
.menu-separator {
height: 1px;
margin: 3px 2px;
background: #808080;
border-bottom: 1px solid #ffffff;
}
.shortcut {
color: #555555;
}
.menu-action:hover .shortcut {
color: #ffffff;
}

View File

@@ -0,0 +1,38 @@
.toast {
position: fixed;
right: 12px;
bottom: 52px;
max-width: min(360px, calc(100vw - 24px));
display: none;
padding: 8px 10px;
color: #000000;
background: #ffffcc;
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
z-index: 60;
font-size: 12px;
line-height: 14px;
box-shadow: 4px 4px 0 rgba(0,0,0,.45);
zoom: var(--ui-scale);
}
.toast.is-visible {
display: block;
animation: toast-in 180ms steps(3, end), toast-buzz 700ms steps(2, end) 180ms;
}
.toast.toast-warning {
color: #000000;
background: #ffffcc;
border: 4px solid transparent;
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 4;
}
.toast.toast-error {
color: #ffffff;
background: #b00000;
text-shadow: 1px 1px 0 #000000;
border-color: #ffb0b0 #330000 #330000 #ffb0b0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
.upload-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
height: 40px;
padding: 0 8px 8px;
}
.start-upload-cta {
min-width: 128px;
position: relative;
overflow: visible;
isolation: isolate;
font-weight: bold;
}
.start-upload-cta.is-current-step {
animation: start-ready-rainbow-breathe 1150ms ease-in-out infinite;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 0 0 1px #000000;
}
.start-upload-cta.is-current-step::after {
content: "";
position: absolute;
inset: -4px;
pointer-events: none;
z-index: 1;
padding: 4px;
background: linear-gradient(90deg, #ff004c, #ffcc00, #00d26a, #00a2ff, #8c48ff, #ff004c, #ffcc00);
background-size: 280% 100%;
opacity: .9;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: start-border-rainbow-slide 1850ms linear infinite;
}

View File

@@ -0,0 +1,101 @@
.duplicate-list,
.quota-dialog-list {
margin: 8px 0;
padding: 6px 6px 6px 28px;
background: #ffffff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
max-height: 180px;
overflow: auto;
}
.quota-dialog-summary,
.quota-note {
padding: 8px;
background: #ffffcc;
border: 1px solid #808080;
}
.quota-meter-list,
.faq-list,
.shortcut-list {
display: grid;
gap: 10px;
}
.quota-meter,
.faq-item,
.shortcut-list li {
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.quota-meter-head {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 5px;
font-weight: bold;
}
.quota-meter-track {
height: 18px;
overflow: hidden;
background: #ffffff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.quota-meter-bar {
display: block;
height: 100%;
background: #000078;
}
.copy-fallback-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.copy-fallback-text {
width: 100%;
min-height: 58px;
font-family: 'MonoCraft', 'PixelOperatorMono', monospace;
}
.popup-body .code-block {
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.popup-body .code-block code {
display: inline-block;
min-width: 100%;
color: inherit;
font: inherit;
white-space: inherit;
user-select: text;
-webkit-user-select: text;
}
.kbd {
display: inline-block;
min-width: 18px;
padding: 1px 5px;
color: #000000;
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
text-align: center;
}

View File

@@ -0,0 +1,95 @@
.modal-backdrop {
position: fixed;
inset: 0;
display: none;
background: rgba(128, 128, 128, .42);
z-index: 70;
}
.modal-backdrop.is-visible {
display: block;
}
.popup-window {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(780px, calc(100vw - 24px));
max-height: min(760px, calc(100vh - 24px));
display: none;
z-index: 80;
zoom: var(--ui-scale);
}
.popup-window.is-visible {
display: flex;
animation: popup-open-v10 180ms steps(5, end);
}
.popup-window.is-about-popup {
width: min(360px, calc(100vw - 28px));
min-height: 220px;
}
.popup-body {
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - 90px);
padding: 12px;
overflow: auto;
font-size: 13px;
line-height: 16px;
}
.popup-body h3 { margin: 0 0 8px; font-size: 16px; line-height: 18px; }
.popup-body h4 { margin: 14px 0 6px; font-size: 14px; line-height: 16px; }
.popup-body p { margin: 0 0 8px; }
.popup-body ul,
.popup-body ol { margin: 0 0 8px 18px; padding: 0; }
.popup-body li { margin: 0 0 4px; }
.popup-body .code-block {
margin: 6px 0 10px;
width: 100%;
max-width: 100%;
display: block;
overflow: auto;
overscroll-behavior: contain;
padding: 8px;
color: #00ff66;
background: #000000;
border: 0;
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
font-size: 12px;
line-height: 15px;
white-space: pre;
user-select: text;
-webkit-user-select: text;
box-sizing: border-box;
contain: layout paint;
}
.popup-window.is-about-popup .popup-body {
display: flex;
flex-direction: column;
justify-content: stretch;
overflow: hidden;
}
.about-popup-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.about-popup-content p:last-child {
margin-top: auto;
margin-bottom: 0;
padding-top: 10px;
}
.popup-close {
cursor: pointer;
}

View File

@@ -0,0 +1,41 @@
.folder-icon-button {
flex: 0 0 86px;
width: 86px;
min-width: 86px;
height: 68px;
display: grid;
grid-template-rows: 34px 1fr;
place-items: center;
gap: 4px;
padding: 4px;
color: #000000;
background: transparent;
border: 1px solid transparent;
font-family: inherit;
font-size: 12px;
line-height: 12px;
}
.folder-icon-button img {
width: 34px;
height: 34px;
object-fit: contain;
image-rendering: pixelated;
}
.folder-icon-button:hover,
.folder-icon-button:focus-visible {
color: #ffffff;
background: #000078;
border: 1px dotted #ffffff;
outline: none;
}
.folder-icon-button-disabled {
color: #606060;
}
.folder-icon-button-disabled img {
filter: grayscale(.9);
opacity: .75;
}

View File

@@ -0,0 +1,43 @@
.upload-main {
height: 100vh;
min-height: 0;
overflow: hidden;
}
.desktop-wrap {
--window-height: 736px;
--side-width: 440px;
width: min(1278px, 100%);
height: min(var(--window-height), calc(100vh - 36px));
max-height: calc(100vh - 36px);
display: grid;
grid-template-columns: minmax(0, 820px) var(--side-width);
grid-template-rows: minmax(0, 1fr);
align-items: stretch;
justify-content: center;
gap: 18px;
overflow: hidden;
zoom: var(--ui-scale);
}
body.fit-window .desktop-wrap {
width: min(100%, calc(100vw / var(--ui-scale) - 20px));
height: min(calc(100vh / var(--ui-scale) - 20px), 900px);
max-height: none;
grid-template-columns: minmax(0, 1fr) var(--side-width);
}
.upload-window {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.upload-form {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}

View File

@@ -0,0 +1,148 @@
.box-options-form {
display: grid;
gap: 8px;
min-height: 100%;
align-content: start;
}
.box-options-form.is-locked {
opacity: .82;
filter: grayscale(.12);
}
.box-options-form.is-locked::after {
content: "Box sealed after upload";
display: block;
margin-top: 8px;
padding: 5px 6px;
color: #000000;
background: #dfdfdf;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-size: 12px;
line-height: 13px;
}
.option-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 6px;
align-items: center;
}
.option-check {
position: relative;
min-height: 18px;
display: flex;
gap: 6px;
align-items: center;
}
.option-check input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
pointer-events: none;
}
.option-check span {
position: relative;
min-height: 16px;
display: inline-flex;
align-items: center;
padding-left: 22px;
}
.option-check span::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
background: #ffffff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf;
}
.option-check input[type="checkbox"]:checked + span::after {
content: "";
position: absolute;
left: 4px;
top: 6px;
width: 2px;
height: 2px;
color: #000000;
background: #000000;
box-shadow:
2px 2px 0 #000000,
4px 4px 0 #000000,
6px 2px 0 #000000,
8px 0 0 #000000,
10px -2px 0 #000000;
image-rendering: pixelated;
}
.upload-select,
.upload-text-input {
width: 100%;
height: 22px;
padding: 1px 4px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-size: 12px;
line-height: 12px;
}
.upload-text-input:disabled,
.upload-select:disabled,
.box-options-form.is-locked input[readonly],
.box-options-form.is-locked input:disabled,
.box-options-form.is-locked select:disabled {
color: #404040;
background: repeating-linear-gradient(45deg, #d0d0d0 0 4px, #c7c7c7 4px 8px);
}
.api-key-row {
display: none;
}
.api-key-row.is-visible {
display: grid;
}
.api-key-field {
position: relative;
display: block;
}
.api-key-state {
position: absolute;
right: 4px;
top: 3px;
color: #000078;
font-size: 11px;
line-height: 12px;
pointer-events: none;
}
.api-key-field.is-checking::after {
content: "";
position: absolute;
inset: 2px;
background: repeating-linear-gradient(90deg, rgba(0,0,120,.16) 0 8px, rgba(15,128,205,.16) 8px 16px);
animation: api-key-scan 700ms steps(6, end) infinite;
pointer-events: none;
}

View File

@@ -0,0 +1,41 @@
.upload-panel {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
margin: 0 8px 8px;
padding: 12px;
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
}
.upload-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 270px;
gap: 10px;
margin-bottom: 10px;
padding: 8px;
color: #000000;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.upload-heading {
margin: 0 0 4px;
font-size: 20px;
line-height: 22px;
font-weight: bold;
}
.upload-subtext {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 15px;
}

323
static/css/upload/queue.css Normal file
View File

@@ -0,0 +1,323 @@
.upload-quota {
min-width: 250px;
padding: 7px;
overflow: hidden;
background: #c7d8f2;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #404040;
border-bottom: 1px solid #404040;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #e9f2ff;
font-size: 12px;
line-height: 13px;
}
.upload-quota strong {
display: block;
margin-bottom: 4px;
font-size: 13px;
}
.upload-quota.is-quota-warning {
background: repeating-linear-gradient(45deg, #ffdede 0 5px, #fff2a8 5px 10px);
border-color: #800000;
animation: quota-warning-breathe 900ms steps(4, end) infinite;
}
.upload-quota-track,
.upload-overall-track,
.upload-progress {
display: block;
min-width: 0;
overflow: hidden;
background-color: #ffffff;
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.05) 0 1px, transparent 1px 18px);
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.upload-quota-track {
width: 100%;
height: 16px;
margin-top: 6px;
}
.upload-quota-bar,
.upload-overall-bar,
.upload-progress-bar {
display: block;
width: 0%;
max-width: 100%;
height: 100%;
background-color: #000078;
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.12) 0 1px, transparent 1px 18px);
transform-origin: left center;
position: relative;
}
.upload-quota-bar.is-over-quota {
background-image: repeating-linear-gradient(45deg, #800000 0 7px, #ffcc00 7px 14px);
}
.upload-dropzone {
flex: 0 0 auto;
min-height: 154px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px;
text-align: center;
color: #000000;
background: repeating-linear-gradient(45deg, #dfdfdf 0 4px, #e9e9e9 4px 8px), #dfdfdf;
border: 1px solid #808080;
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080, inset 2px 2px 0 rgba(0,0,0,.18), 0 1px 0 rgba(255,255,255,.7);
}
.upload-dropzone.is-dragging,
.upload-dropzone:hover {
background: repeating-linear-gradient(45deg, #c7d8f2 0 4px, #d8e5f8 4px 8px), #c7d8f2;
outline: 2px dashed #000078;
outline-offset: -6px;
}
.upload-dropzone.is-current-step {
animation: dropzone-attention 1500ms steps(5, end) infinite;
}
.upload-dropzone.is-locked {
opacity: .72;
cursor: not-allowed;
filter: grayscale(.3);
}
.upload-icon-img {
width: 34px;
height: 34px;
object-fit: contain;
image-rendering: pixelated;
}
.upload-primary {
font-size: 18px;
line-height: 18px;
font-weight: bold;
}
.upload-secondary {
color: #333333;
font-size: 13px;
line-height: 15px;
}
.upload-linklike {
color: #000078;
text-decoration: underline;
font-weight: bold;
}
.upload-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.upload-details {
display: flex;
align-items: center;
min-height: 28px;
margin-top: 12px;
padding: 5px 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #dfdfdf;
border-bottom: 1px solid #dfdfdf;
box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75);
font-size: 13px;
line-height: 13px;
}
.upload-detail-label {
flex: 0 0 auto;
margin-right: 6px;
font-weight: bold;
}
.upload-file-count {
margin-left: auto;
}
.upload-file-list {
flex: 1 1 auto;
min-height: 0;
margin-top: 8px;
overflow-y: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.upload-empty-state {
margin: 0;
padding: 10px 8px;
color: #555555;
font-size: 13px;
line-height: 15px;
}
.upload-file-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 82px 30px;
grid-template-rows: 20px 8px;
align-items: center;
height: 38px;
padding: 4px 8px;
border-bottom: 1px solid #dfdfdf;
font-size: 13px;
line-height: 13px;
column-gap: 6px;
}
.upload-file-row:nth-child(odd) { background: rgba(255,255,255,.92); }
.upload-file-row:nth-child(even) { background: rgba(240,244,255,.88); }
.upload-file-row:hover { background: #d8e5f8; }
.upload-file-row.is-working { animation: upload-row-loading 900ms steps(2, end) infinite; }
.upload-file-row.is-failed { background: #ffe2e2 !important; }
.upload-file-row.is-too-large { position: relative; background: #fff0b8 !important; animation: row-warning-breathe 900ms steps(4, end) infinite; }
.upload-file-row.is-too-large::after {
content: "";
position: absolute;
inset: 1px;
pointer-events: none;
border: 2px solid transparent;
border-image: repeating-linear-gradient(90deg, #800000 0 8px, #ffcc00 8px 16px) 1;
}
.upload-file-icon {
grid-row: 1 / 3;
width: 18px;
height: 18px;
display: grid;
place-items: center;
object-fit: contain;
image-rendering: pixelated;
}
.upload-file-row.has-thumbnail .upload-file-icon {
width: 20px;
height: 20px;
object-fit: cover;
background: #ffffff;
border: 1px solid #808080;
}
.upload-file-name,
.upload-file-size {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-file-size {
text-align: right;
color: #333333;
}
.upload-file-remove {
grid-column: 4;
grid-row: 1 / 3;
justify-self: end;
width: 22px;
min-width: 22px;
height: 22px;
padding: 0;
font-size: 12px;
}
.upload-progress {
grid-column: 2 / 4;
grid-row: 2;
height: 8px;
width: 100%;
border-width: 1px;
}
.upload-file-row.is-uploaded .upload-progress-bar { background-color: #008000; }
.upload-file-row.is-failed .upload-progress-bar { width: 100%; background-color: #800000; }
.upload-progress-bar.just-completed,
.upload-overall-bar.just-completed {
animation: progress-impact-bar 520ms steps(5, end) 1;
}
.upload-progress-bar.just-completed::after,
.upload-overall-bar.just-completed::after {
content: "";
position: absolute;
right: -7px;
top: 50%;
width: 12px;
height: 22px;
transform: translateY(-50%);
background: repeating-linear-gradient(45deg, rgba(255,255,255,.95) 0 2px, rgba(0,255,102,.85) 2px 4px, transparent 4px 6px);
box-shadow: 0 0 0 1px #ffffff, 0 0 8px #00ff66;
pointer-events: none;
animation: progress-impact-spark 520ms steps(5, end) 1;
}
.upload-result {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 72px;
align-items: center;
gap: 6px;
min-height: 36px;
margin-top: 8px;
padding: 4px 6px;
background: #dfdfdf;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75);
font-size: 12px;
line-height: 12px;
}
.upload-result.is-current-step {
animation: share-ready-pulse 1100ms steps(4, end) infinite;
}
.upload-result-label { font-weight: bold; }
.upload-result-link { min-width: 0; overflow: hidden; color: #000078; text-overflow: ellipsis; white-space: nowrap; }
.upload-result-link.is-empty { color: #555555; text-decoration: none; pointer-events: none; }
.upload-share-button { min-width: 72px; width: 72px; height: 24px; font-size: 12px; line-height: 12px; }
.upload-overall {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px 8px;
font-size: 12px;
line-height: 12px;
}
.upload-overall-track {
height: 18px;
}
.upload-overall-percent {
min-width: 0;
text-align: right;
}

View File

@@ -0,0 +1,123 @@
@keyframes upload-row-loading { 0% { background-color: #ffffff; } 100% { background-color: #e6e6e6; } }
@keyframes quota-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.08); } }
@keyframes row-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.12); } }
@keyframes dropzone-attention { 0%, 100% { filter: brightness(1); transform: translateY(0); } 50% { filter: brightness(1.07); transform: translateY(-1px); } }
@keyframes share-ready-pulse { 50% { filter: brightness(1.08); box-shadow: 0 0 0 2px #000078; } }
@keyframes start-ready-rainbow-breathe { 0%, 100% { transform: rotate(-.35deg) scale(1); } 50% { transform: rotate(.35deg) scale(1.016); } }
@keyframes start-border-rainbow-slide { from { background-position: 0% 50%; } to { background-position: 100% 50%; } }
@keyframes progress-impact-bar { 0% { filter: brightness(1); } 35% { filter: brightness(1.75); } 100% { filter: brightness(1); } }
@keyframes progress-impact-spark { 0% { opacity: 0; transform: translateY(-50%) scale(.7); } 30% { opacity: 1; transform: translateY(-50%) scale(1.18); } 100% { opacity: 0; transform: translateY(-50%) scale(.7); } }
@keyframes terminal-cursor { 50% { opacity: 0; } }
@keyframes popup-open-v10 { from { transform: translate(-50%, -48%) scale(.97); opacity: .35; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
@keyframes toast-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes toast-buzz { 0%, 100% { margin-right: 0; } 25% { margin-right: 2px; } 50% { margin-right: -2px; } }
@keyframes api-key-scan { to { background-position: 32px 0; } }
@media (max-width: 1320px) {
body { height: auto; min-height: 100vh; overflow-y: auto; }
.upload-main { height: auto; min-height: 100vh; place-items: start center; overflow: visible; }
.desktop-wrap {
--window-height: 680px;
grid-template-columns: minmax(0, 820px);
grid-template-rows: var(--window-height) auto;
width: min(820px, 100%);
max-width: 820px;
height: auto;
max-height: none;
overflow: visible;
}
.side-stack {
width: 100%;
min-width: 0;
max-width: none;
height: auto;
grid-template-columns: 1fr;
grid-template-rows: 350px 210px 132px;
overflow: visible;
}
.side-panel,
.helper-window {
width: 100%;
min-width: 0;
max-width: none;
}
}
@media (min-width: 1440px) {
.desktop-wrap { --window-height: 780px; }
.side-stack { grid-template-rows: 372px 230px 1fr; }
}
@media (max-width: 760px) {
.upload-main {
height: auto;
min-height: 100dvh;
place-items: stretch;
align-items: stretch;
padding: 0;
overflow: visible;
}
.desktop-wrap {
width: 100%;
max-width: none;
height: auto;
max-height: none;
min-height: 100dvh;
gap: 10px;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
overflow: visible;
}
.upload-window {
min-height: 100dvh;
height: auto;
width: 100vw;
border-left: 0;
border-right: 0;
box-shadow: none;
}
.side-stack {
grid-template-rows: auto auto auto;
padding: 0 6px 12px;
}
.side-panel:first-child { min-height: 360px; }
.side-panel:nth-child(2) { min-height: 210px; }
.helper-window { min-height: 128px; }
.upload-header { grid-template-columns: 1fr; }
.upload-panel { margin: 0 6px 8px; padding: 10px; }
.upload-dropzone { min-height: 118px; padding: 14px 10px; }
.upload-primary { font-size: 16px; }
.upload-details { flex-wrap: wrap; gap: 4px; }
.upload-file-count { margin-left: 0; width: 100%; }
.upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 58px 28px; padding: 4px 5px; font-size: 12px; }
.upload-result { grid-template-columns: 1fr 72px; }
.upload-result-label { grid-column: 1 / 3; }
.upload-actions { justify-content: stretch; }
.upload-actions .win98-button { flex: 1; min-width: 0; }
.menu-bar { overflow-x: auto; }
.menu-popup { position: fixed; left: 6px; right: 6px; top: 50px; min-width: 0; }
.popup-window {
left: 0;
top: 0;
transform: none;
width: 100vw;
height: 100dvh;
max-height: none;
border: 0;
box-shadow: none;
}
.popup-window .win98-titlebar { height: 32px; }
.popup-close { width: 28px; height: 24px; font-size: 18px; font-weight: bold; }
.popup-body { max-height: calc(100dvh - 40px); }
.popup-window.is-visible { animation: popup-open-mobile-v10 160ms steps(5, end); }
@keyframes popup-open-mobile-v10 { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
}
@media (max-width: 420px) {
:root { --base-font-size: 13px; }
.win98-titlebar h1 { font-size: 13px; }
.upload-file-size { display: none; }
.upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 28px; }
.upload-file-remove { grid-column: 3; }
.upload-progress { grid-column: 2 / 3; }
}

View File

@@ -0,0 +1,50 @@
.upload-statusbar {
grid-template-columns: 1fr 100px;
}
.side-stack {
width: var(--side-width);
min-width: var(--side-width);
max-width: var(--side-width);
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: var(--side-width);
grid-template-rows: 350px 210px 1fr;
gap: 12px;
overflow: hidden;
}
.side-panel,
.helper-window {
width: var(--side-width);
min-width: var(--side-width);
max-width: var(--side-width);
min-height: 0;
overflow: hidden;
}
.side-panel {
display: flex;
flex-direction: column;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
}
.side-body,
.helper-body,
.popup-body {
margin: 0 6px 6px;
padding: 9px;
color: #000000;
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
font-size: 13px;
line-height: 15px;
}
.side-body {
flex: 1 1 auto;
overflow: auto;
}

View File

@@ -0,0 +1,54 @@
.terminal-box {
flex: 1 1 auto;
min-height: 104px;
max-height: 134px;
overflow: auto;
padding: 10px;
color: #b4efbd;
background-color: #030403;
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
border: 0;
box-shadow: inset 1px 1px 0 #000000, inset -1px -1px 0 rgba(255,255,255,.22);
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
font-size: 13px;
line-height: 16px;
white-space: pre-wrap;
}
.terminal-box::after {
content: "█";
display: inline-block;
margin-left: 2px;
color: #7dff8a;
animation: terminal-cursor 1s steps(2, end) infinite;
}
.terminal-muted {
color: #79ad83;
}
.terminal-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 2px;
}
.terminal-copy-button {
min-width: 148px;
height: 24px;
font-size: 12px;
line-height: 12px;
}
.helper-body {
height: calc(100% - 34px);
min-height: 0;
display: flex;
justify-content: flex-start;
align-content: flex-start;
align-items: flex-start;
flex-wrap: wrap;
gap: 8px;
overflow: auto;
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,7 @@ const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
let contextFile = null; let contextFile = null;
let lastStatusSignature = ""; let lastStatusSignature = "";
function htmlEscape(value) { const htmlEscape = window.WarpBoxUI.htmlEscape;
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function showToast(message, type = "info") { function showToast(message, type = "info") {
window.WarpBoxUI.toast(message, type, { target: toast }); window.WarpBoxUI.toast(message, type, { target: toast });
@@ -302,23 +295,39 @@ function startStagedPolling(baseMS) {
window.setTimeout(tick, stages[0].interval); window.setTimeout(tick, stages[0].interval);
} }
function runBoxAction(action) {
const actions = {
"fake-close": () => showToast("Close clicked. The download window is emotionally attached.", "warning"),
minimize: () => showToast("Minimize clicked. WarpBox refuses to disappear quietly."),
"toggle-fit": () => {
document.body.classList.toggle("fit-window");
showToast("Maximize clicked. The window is doing its best.");
},
};
actions[action]?.();
}
function runContextAction(action, item) {
const actions = {
preview: () => previewFile(item),
download: () => downloadFile(item),
properties: () => showProperties(item),
};
actions[action]?.();
}
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const action = event.target.closest("[data-action]")?.dataset.action; const action = event.target.closest("[data-action]")?.dataset.action;
if (action === "fake-close") showToast("Close clicked. The download window is emotionally attached.", "warning"); if (action) runBoxAction(action);
if (action === "minimize") showToast("Minimize clicked. WarpBox refuses to disappear quietly.");
if (action === "toggle-fit") {
document.body.classList.toggle("fit-window");
showToast("Maximize clicked. The window is doing its best.");
}
const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction; const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction;
if (contextAction && contextFile) { if (contextAction && contextFile) {
event.preventDefault(); event.preventDefault();
const item = contextFile; const item = contextFile;
closeContextMenu(); closeContextMenu();
if (contextAction === "preview") previewFile(item); runContextAction(contextAction, item);
if (contextAction === "download") downloadFile(item);
if (contextAction === "properties") showProperties(item);
return; return;
} }

139
static/js/upload/api.js Normal file
View File

@@ -0,0 +1,139 @@
async function createBox() {
const response = await fetch("/box", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
retention_key: el.expiry?.value || defaultRetention,
password: el.password?.value || "",
allow_zip: isOneTimeDownloadSelected() || !el.allowZip || el.allowZip.checked,
files: files.map((item) => ({ name: item.displayName, size: item.file.size })),
}),
});
const result = await readJSON(response);
if (!response.ok) throw new Error(result.error || "Could not create upload box");
return result;
}
async function readJSON(response) {
try {
return await response.json();
} catch (_) {
return {};
}
}
async function markFileStatus(item, status) {
if (!item.boxID || !item.boxFile) return;
try {
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
} catch (_) {
// Best effort only. The upload endpoint also marks hard failures.
}
}
function setFileFailed(item, message) {
item.failed = true;
item.uploaded = false;
item.error = message || "Failed to upload";
item.loaded = item.file.size;
item.row?.classList.remove("is-working", "is-uploaded");
item.row?.classList.add("is-failed");
if (item.row) item.row.title = item.error;
setRowProgress(item, 100);
updateOverallProgress();
}
function markCompletedImpact(item) {
const key = item.boxFile?.id || item.displayName;
if (completedImpactKeys.has(key)) return;
completedImpactKeys.add(key);
flashProgressBar(item.row?.querySelector(".upload-progress-bar"));
}
function uploadFile(item, onComplete) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", item.file, item.displayName);
xhr.open("POST", item.boxFile.upload_path);
xhr.upload.addEventListener("loadstart", () => {
item.loaded = 0;
item.failed = false;
item.uploaded = false;
item.row?.classList.remove("is-failed", "is-uploaded");
item.row?.classList.add("is-working");
setRowProgress(item, 2);
updateOverallProgress();
});
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable) return;
item.loaded = Math.min(event.loaded, item.file.size);
const percent = (event.loaded / event.total) * 100;
setRowProgress(item, percent >= 100 ? 99 : percent);
updateOverallProgress();
});
xhr.addEventListener("load", async () => {
if (xhr.status < 200 || xhr.status >= 300) {
let message = "Upload failed";
try {
message = JSON.parse(xhr.responseText).error || message;
} catch (_) {}
setFileFailed(item, message);
await markFileStatus(item, "failed");
reject(new Error(message));
return;
}
item.uploaded = true;
item.failed = false;
item.loaded = item.file.size;
item.row?.classList.remove("is-working", "is-failed");
item.row?.classList.add("is-uploaded");
if (item.row) item.row.title = "Uploaded";
setRowProgress(item, 100);
markCompletedImpact(item);
try {
const result = JSON.parse(xhr.responseText);
if (result.file) {
item.boxFile = result.file;
const icon = item.row?.querySelector(".upload-file-icon");
if (icon && result.file.thumbnail_path) {
item.row.classList.add("has-thumbnail");
icon.src = result.file.thumbnail_path;
} else if (icon && result.file.icon_path && !item.previewURL) {
icon.src = result.file.icon_path;
}
}
} catch (_) {}
updateOverallProgress();
onComplete();
resolve();
});
xhr.addEventListener("error", async () => {
setFileFailed(item, "Network error while uploading");
await markFileStatus(item, "failed");
reject(new Error("Network error while uploading"));
});
xhr.addEventListener("abort", async () => {
setFileFailed(item, "Upload cancelled");
await markFileStatus(item, "failed");
reject(new Error("Upload cancelled"));
});
markFileStatus(item, "uploading");
xhr.send(formData);
});
}

232
static/js/upload/dom.js Normal file
View File

@@ -0,0 +1,232 @@
function setStatus(message) {
if (el.statusText) el.statusText.textContent = message;
}
function showToast(message, type = "info") {
window.WarpBoxUI.toast(message, type, { target: el.toast });
}
function closeMenus() {
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
node.classList.remove("is-open");
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
function disabledReasonFor(target) {
const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone, .option-check, .option-row");
if (!control) return "";
if (control.classList.contains("option-check") || control.classList.contains("option-row")) {
const nested = control.querySelector("input, select, textarea");
if (nested?.disabled || nested?.readOnly || nested?.getAttribute("aria-disabled") === "true") {
return nested.dataset.disabledReason || "This option is disabled right now.";
}
}
if (control.classList.contains("upload-dropzone") && uploadLocked) {
return control.dataset.disabledReason || "The current box is sealed after upload. Press Clear to start a new box.";
}
if (control.disabled || control.readOnly || control.getAttribute("aria-disabled") === "true") {
return control.dataset.disabledReason || control.title || "This control is disabled right now.";
}
return "";
}
function announceDisabledReason(event) {
const reason = disabledReasonFor(event.target);
if (!reason) return false;
event.preventDefault();
event.stopPropagation();
closeMenus();
showToast(reason, "warning");
setStatus(reason);
return true;
}
function stopStatusAnimation() {
if (statusTimer) {
clearInterval(statusTimer);
statusTimer = null;
}
}
function animateUploadStatus(getPrefix) {
let dotCount = 0;
stopStatusAnimation();
statusTimer = setInterval(() => {
dotCount = (dotCount % 3) + 1;
setStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`);
}, 350);
}
function setShareUrl(url) {
shareUrl = url ? new URL(url, window.location.origin).toString() : "";
if (!el.shareLink || !el.copyButton) return;
el.shareLink.textContent = shareUrl || "Not created yet";
el.shareLink.href = shareUrl || "#";
el.shareLink.title = shareUrl;
el.shareLink.classList.toggle("is-empty", !shareUrl);
el.shareLink.setAttribute("aria-disabled", shareUrl ? "false" : "true");
el.copyButton.disabled = false;
el.copyButton.setAttribute("aria-disabled", shareUrl ? "false" : "true");
el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
updateDisabledReasons();
updateTerminal();
updateCurrentStep();
}
function setOverallProgress(percent) {
const clamped = Math.max(0, Math.min(100, percent));
const display = `${Math.round(clamped)}%`;
if (el.overallBar) el.overallBar.style.width = display;
if (el.overallPercent) el.overallPercent.textContent = display;
}
function flashProgressBar(bar) {
if (!bar) return;
bar.classList.remove("just-completed");
void bar.offsetWidth;
bar.classList.add("just-completed");
setTimeout(() => bar.classList.remove("just-completed"), 620);
}
function setRowProgress(item, percent) {
const bar = item.row?.querySelector(".upload-progress-bar");
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
}
function updateCurrentStep() {
const hasFiles = files.length > 0;
const allDone = hasFiles && files.every((item) => item.uploaded);
el.dropzone?.classList.toggle("is-current-step", uploadsEnabled && !hasFiles && !uploadLocked);
el.startButton?.classList.toggle("is-current-step", uploadsEnabled && hasFiles && !allDone && !uploadLocked && !hasQuotaError());
document.querySelector(".upload-result")?.classList.toggle("is-current-step", allDone && Boolean(shareUrl));
}
function quotaWarningMessage(incoming = []) {
const combined = [...files, ...incoming];
const tooBig = maxFileBytes ? combined.filter((item) => item.file.size > maxFileBytes) : [];
const total = combined.reduce((sum, item) => sum + item.file.size, 0);
if (tooBig.length) {
const list = tooBig.slice(0, 4).map((item) => `${item.displayName} (${formatBytes(item.file.size)})`).join(", ");
const more = tooBig.length > 4 ? ` and ${tooBig.length - 4} more` : "";
return `These files are over the single-file limit of ${formatBytes(maxFileBytes)}: ${list}${more}. Remove them before uploading.`;
}
if (maxBoxBytes && total > maxBoxBytes) {
return `This box is ${formatBytes(total - maxBoxBytes)} over the ${formatBytes(maxBoxBytes)} limit. Remove some files before uploading.`;
}
return "";
}
function updateLimitHint() {
if (!el.limitHint) return;
const parts = [];
if (maxBoxBytes) parts.push(`Max box: ${formatBytes(maxBoxBytes)}`);
if (maxFileBytes) parts.push(`max file: ${formatBytes(maxFileBytes)}`);
parts.push("links expire automatically");
el.limitHint.textContent = parts.join(" · ");
}
function updateQuota() {
const used = totalBytes();
const limitText = maxBoxBytes ? ` / ${formatBytes(maxBoxBytes)}` : "";
const overQuota = isOverBoxQuota();
const overFile = oversizedFiles().length > 0;
const percent = maxBoxBytes ? Math.min(100, Math.round((used / maxBoxBytes) * 100)) : 0;
document.querySelector(".upload-quota")?.classList.toggle("is-quota-warning", overQuota || overFile);
if (el.boxSpaceText) el.boxSpaceText.textContent = `${formatBytes(used)}${limitText}${overQuota ? " - over quota" : ""}`;
if (el.boxSpaceBar) {
el.boxSpaceBar.style.width = `${percent}%`;
el.boxSpaceBar.classList.toggle("is-over-quota", overQuota || overFile);
}
}
function updateQueueSummary() {
const count = files.length;
if (el.queueLabel) el.queueLabel.textContent = count ? `${count} file${count === 1 ? "" : "s"} selected` : "No files selected";
if (el.queueSize) el.queueSize.textContent = `${formatBytes(totalBytes())} total`;
}
function updateOverallProgress() {
const uploadedCount = files.filter((item) => item.uploaded).length;
const percent = overallProgress();
setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent);
if (percent >= 100 && files.length && !overallImpactDone) {
overallImpactDone = true;
flashProgressBar(el.overallBar);
}
}
function createFileRow(item, index) {
const row = document.createElement("div");
row.className = "upload-file-row";
row.dataset.index = String(index);
row.classList.toggle("has-thumbnail", Boolean(item.previewURL));
row.classList.toggle("is-too-large", maxFileBytes > 0 && item.file.size > maxFileBytes);
row.classList.toggle("is-working", item.loaded > 0 && !item.uploaded && !item.failed);
row.classList.toggle("is-uploaded", item.uploaded);
row.classList.toggle("is-failed", item.failed);
row.title = item.error || "";
const icon = document.createElement("img");
icon.className = "upload-file-icon";
icon.src = item.previewURL || iconForFile(item.file);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "upload-file-name";
name.textContent = item.displayName;
name.title = item.displayName;
const size = document.createElement("span");
size.className = "upload-file-size";
size.textContent = formatBytes(item.file.size);
const remove = document.createElement("button");
remove.className = "win98-button upload-file-remove";
remove.type = "button";
remove.textContent = "×";
remove.dataset.remove = String(index);
remove.title = uploadLocked ? "This file cannot be removed because this upload box was already created." : "Remove file";
remove.disabled = false;
remove.setAttribute("aria-disabled", uploadLocked ? "true" : "false");
remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : "";
const progress = document.createElement("span");
progress.className = "upload-progress";
progress.setAttribute("aria-label", `Upload progress ${Math.round(item.file.size ? (item.loaded / item.file.size) * 100 : 0)} percent`);
const progressBar = document.createElement("span");
progressBar.className = "upload-progress-bar";
progressBar.style.width = `${item.uploaded ? 100 : item.failed ? 100 : Math.max(0, Math.min(100, item.file.size ? (item.loaded / item.file.size) * 100 : 0))}%`;
progress.append(progressBar);
row.append(icon, name, size, remove, progress);
item.row = row;
return row;
}
function renderFiles() {
if (!el.fileList) return;
el.fileList.replaceChildren();
if (!files.length) {
const empty = document.createElement("p");
empty.className = "upload-empty-state";
empty.textContent = uploadsEnabled
? "No files in the box yet. Drop files here, use File > Add files, or click the dropzone."
: "Guest uploads are disabled.";
el.fileList.append(empty);
} else {
const fragment = document.createDocumentFragment();
files.forEach((item, index) => fragment.append(createFileRow(item, index)));
el.fileList.append(fragment);
}
updateQueueSummary();
updateQuota();
updateOverallProgress();
updateTerminal();
updateDisabledReasons();
updateCurrentStep();
}

237
static/js/upload/events.js Normal file
View File

@@ -0,0 +1,237 @@
function runUploadAction(action) {
const actions = {
browse: () => el.fileInput?.click(),
"start-upload": () => startUpload(),
"copy-link": () => copyText("Share URL", shareUrl, shareUrl),
clear: () => confirmClearQueue(),
"toggle-delete-once": () => {
if (!el.expiry?.querySelector(`option[value="${oneTimeRetentionKey}"]`)) return;
el.expiry.value = isOneTimeDownloadSelected() ? defaultRetention : oneTimeRetentionKey;
syncZipForRetention();
saveSettings();
syncMenuChecks();
updateTerminal();
},
"random-password": () => randomPassword(),
"random-box-name": () => randomBoxName(),
"clear-password": () => {
if (!el.password || uploadLocked) return;
el.password.value = "";
saveSettings();
updateTerminal();
},
"toggle-page": () => {
if (!el.downloadPage || uploadLocked) return;
el.downloadPage.checked = !el.downloadPage.checked;
saveSettings();
syncMenuChecks();
},
help: () => openDoc("faq"),
"side-help": () => {
openDoc("faq");
showToast("Terminal help opened. Copy the command and feed it files.");
},
"coming-soon": () => showToast("Coming Soon, not implemented just yet."),
"fake-close": () => showToast("Close button denied. The upload window is staying open.", "warning"),
minimize: () => showToast("Minimize requested. WarpBox stays visible so your queue is safe."),
"toggle-fit": () => {
document.body.classList.toggle("fit-window");
showToast("Maximize requested. The pixel rectangle feels important now.");
},
"side-close": () => showToast("Box Options refuses to leave. Settings stay visible."),
"side-folder-close": () => showToast("The folder window saw that click and chose denial."),
};
actions[action]?.();
}
document.addEventListener("click", (event) => {
if (announceDisabledReason(event)) return;
const menuButton = event.target.closest(".menu-button");
if (menuButton) {
const item = menuButton.closest(".menu-item");
const isOpen = item.classList.contains("is-open");
closeMenus();
item.classList.toggle("is-open", !isOpen);
menuButton.setAttribute("aria-expanded", String(!isOpen));
return;
}
const action = event.target.closest("[data-action]")?.dataset.action;
if (action) {
closeMenus();
runUploadAction(action);
return;
}
const doc = event.target.closest("[data-doc]")?.dataset.doc;
if (doc) {
openDoc(doc);
return;
}
const remove = event.target.closest("[data-remove]");
if (remove) {
removeFile(Number(remove.dataset.remove));
return;
}
if (event.target.id === "duplicate-append") appendPendingDuplicates();
if (event.target.id === "duplicate-skip") {
pendingDuplicateFiles = [];
closeDoc();
showToast("Duplicate files skipped.");
}
if (event.target.id === "confirm-clear-yes") {
closeDoc();
clearQueue();
}
if (event.target.id === "confirm-clear-no" || event.target.id === "fallback-close") closeDoc();
if (!event.target.closest(".menu-item")) {
closeMenus();
}
});
document.addEventListener("mousedown", (event) => {
announceDisabledReason(event);
}, true);
document.querySelectorAll(".menu-item").forEach((item) => {
item.addEventListener("mouseenter", () => {
if (!document.querySelector(".menu-item.is-open")) return;
closeMenus();
item.classList.add("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
});
});
el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files));
[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => {
target.addEventListener("dragover", (event) => {
event.preventDefault();
el.dropzone?.classList.add("is-dragging");
});
target.addEventListener("dragleave", () => el.dropzone?.classList.remove("is-dragging"));
target.addEventListener("drop", (event) => {
event.preventDefault();
el.dropzone?.classList.remove("is-dragging");
addFiles(event.dataTransfer.files);
});
});
el.dropzone?.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
el.fileInput?.click();
}
});
el.form?.addEventListener("submit", (event) => {
event.preventDefault();
startUpload();
});
el.copyButton?.addEventListener("click", () => copyText("Share URL", shareUrl, shareUrl));
el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getCurlCommand({ full: true })));
el.docPopupClose?.addEventListener("click", closeDoc);
el.modalBackdrop?.addEventListener("click", closeDoc);
el.maxViews?.addEventListener("wheel", (event) => {
if (el.maxViews.disabled || el.maxViews.readOnly) return;
event.preventDefault();
const delta = event.deltaY < 0 ? 1 : -1;
const modifier = event.ctrlKey && event.shiftKey ? 50 : event.shiftKey ? 15 : event.ctrlKey ? 5 : 1;
const min = Number.parseInt(el.maxViews.min || "1", 10);
const max = Number.parseInt(el.maxViews.max || "9999", 10);
const current = Number.parseInt(el.maxViews.value || String(min), 10);
el.maxViews.value = String(Math.max(min, Math.min(max, current + (delta * modifier))));
saveSettings();
updateTerminal();
});
el.apiKeyInput?.addEventListener("keydown", (event) => {
const allowed = event.ctrlKey || event.metaKey || event.altKey || [
"Tab",
"Shift",
"Control",
"Alt",
"Meta",
"Escape",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
].includes(event.key);
if (allowed) return;
event.preventDefault();
showToast("Only pasting the API key is supported.", "warning");
setStatus("Only pasting the API key is supported");
});
el.apiKeyInput?.addEventListener("paste", () => {
setTimeout(validateApiKeyField, 0);
});
[el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean).forEach((control) => {
control.addEventListener("input", () => {
if (control === el.boxName) syncSlugFromName();
if (control === el.customSlug) {
const clean = sanitizeSlugInput(el.customSlug.value);
if (el.customSlug.value !== clean) el.customSlug.value = clean;
el.customSlug.dataset.auto = "false";
}
if (control === el.apiKeyInput) validateApiKeyField();
saveSettings();
updateTerminal();
});
control.addEventListener("change", () => {
if (control === el.expiry) syncZipForRetention();
if (control === el.apiKeyMode) syncApiKeyField();
saveSettings();
syncMenuChecks();
updateTerminal();
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeDoc();
closeMenus();
}
if (event.key === "F1") {
event.preventDefault();
openDoc("faq");
}
if (event.ctrlKey && !event.shiftKey && !event.altKey) {
const key = event.key.toLowerCase();
if (key === "o") {
event.preventDefault();
el.fileInput?.click();
}
if (key === "u") {
event.preventDefault();
startUpload();
}
if (key === "k") {
event.preventDefault();
copyText("cURL command", getCurlCommand({ full: true }));
}
if (key === "l") {
event.preventDefault();
copyText("Share URL", shareUrl, shareUrl);
}
}
});
window.addEventListener("beforeunload", () => {
files.forEach((item) => {
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
});
});

108
static/js/upload/files.js Normal file
View File

@@ -0,0 +1,108 @@
function duplicateFileReport(incoming = []) {
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
const duplicates = [];
const unique = [];
incoming.forEach((item) => {
const key = normalizedFileName(item.displayName);
if (used.has(key)) {
duplicates.push(item);
return;
}
used.add(key);
unique.push(item);
});
return { unique, duplicates };
}
function addFiles(fileList) {
if (!uploadsEnabled) {
showToast("Guest uploads are disabled.", "warning");
return;
}
if (uploadLocked) {
showToast("This box is sealed. Clear it to create a fresh upload.", "warning");
return;
}
const incoming = Array.from(fileList || []).map((file) => makeQueuedFile(file));
if (!incoming.length) return;
const { unique, duplicates } = duplicateFileReport(incoming);
if (unique.length) {
files.push(...unique);
setShareUrl("");
renderFiles();
const warning = quotaWarningMessage();
if (warning) showWarningDialog("Quota warning", warning);
}
if (duplicates.length) showDuplicateDialog(duplicates);
if (unique.length) setStatus(`${unique.length} file${unique.length === 1 ? "" : "s"} added to queue`);
if (duplicates.length && !unique.length) setStatus(`${duplicates.length} duplicate file${duplicates.length === 1 ? "" : "s"} need your choice`);
}
function showDuplicateDialog(duplicates) {
pendingDuplicateFiles = duplicates;
const list = duplicates.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)}</span></li>`).join("");
showTemplatePopup("Duplicate file names", "duplicate", { list })
.then(() => document.querySelector("#duplicate-append")?.focus());
showToast("Duplicate names found. Choose skip or append numbers.", "warning");
}
function appendPendingDuplicates() {
if (!pendingDuplicateFiles.length) return;
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
pendingDuplicateFiles.forEach((item) => {
item.displayName = nextIncrementedFileName(item.displayName, used);
files.push(item);
});
const count = pendingDuplicateFiles.length;
pendingDuplicateFiles = [];
closeDoc();
setShareUrl("");
renderFiles();
showToast("Duplicate files added with numbered names.", "info");
setStatus(`${count} duplicate file${count === 1 ? "" : "s"} added with numbered names`);
}
function removeFile(index) {
if (uploadLocked) {
showToast("Box already created. Clear it before editing the queue.", "warning");
return;
}
const [removed] = files.splice(index, 1);
if (removed?.previewURL) URL.revokeObjectURL(removed.previewURL);
setShareUrl("");
renderFiles();
setStatus("File removed from queue");
}
function clearQueue() {
files.forEach((item) => {
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
});
files = [];
pendingDuplicateFiles = [];
uploadLocked = false;
completedImpactKeys = new Set();
overallImpactDone = false;
stopStatusAnimation();
setBoxOptionsLocked(false);
setShareUrl("");
if (el.fileInput) {
el.fileInput.value = "";
el.fileInput.disabled = !uploadsEnabled;
}
el.dropzone?.classList.remove("is-locked");
renderFiles();
setStatus(uploadsEnabled ? "Queue cleared" : "Guest uploads are disabled");
showToast("Queue cleared.");
}
function confirmClearQueue() {
if (!files.length && !shareUrl) {
showToast("Nothing to clear.");
return;
}
showTemplatePopup("Clear WarpBox?", "clear")
.then(() => document.querySelector("#confirm-clear-no")?.focus());
}

192
static/js/upload/options.js Normal file
View File

@@ -0,0 +1,192 @@
function isOneTimeDownloadSelected() {
return el.expiry?.value === oneTimeRetentionKey;
}
function syncZipForRetention() {
if (!el.allowZip) return;
if (isOneTimeDownloadSelected()) {
el.allowZip.checked = true;
el.allowZip.disabled = true;
} else if (!uploadLocked) {
el.allowZip.disabled = false;
}
}
function setBoxOptionsLocked(locked) {
const controls = [el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean);
el.optionsForm?.classList.toggle("is-locked", locked);
controls.forEach((control) => {
control.dataset.disabledReason = locked ? "Box Options are locked because this box was already created. Press Clear to start another upload." : "";
if (control.tagName === "INPUT" && !["checkbox", "radio", "file"].includes(control.type)) {
control.readOnly = locked;
} else {
control.disabled = locked;
}
});
if (el.password) el.password.type = locked ? "password" : "text";
if (!locked) {
syncZipForRetention();
syncApiKeyField();
}
updateDisabledReasons();
}
function updateDisabledReasons() {
if (el.startButton) {
let reason = "";
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = false;
el.startButton.setAttribute("aria-disabled", reason ? "true" : "false");
el.startButton.dataset.disabledReason = reason;
el.startButton.title = reason;
}
if (el.fileInput) {
el.fileInput.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
}
if (el.dropzone) {
el.dropzone.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
}
document.querySelectorAll('[data-action="start-upload"]').forEach((button) => {
const reason = el.startButton?.dataset.disabledReason || "";
button.setAttribute("aria-disabled", reason ? "true" : "false");
button.dataset.disabledReason = reason;
});
document.querySelectorAll('[data-action="browse"]').forEach((button) => {
const reason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
button.setAttribute("aria-disabled", reason ? "true" : "false");
button.dataset.disabledReason = reason;
});
document.querySelectorAll('[data-action="copy-link"]').forEach((button) => {
button.setAttribute("aria-disabled", shareUrl ? "false" : "true");
button.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
});
}
function saveSettings() {
const apiKey = el.apiKeyMode?.checked && validApiKey(el.apiKeyInput?.value || "") ? el.apiKeyInput.value.trim() : "";
const settings = {
maxViews: el.maxViews?.value || "",
allowPreview: Boolean(el.allowPreview?.checked),
keepFilenames: Boolean(el.keepFilenames?.checked),
privateBox: Boolean(el.privateBox?.checked),
apiKeyMode: Boolean(el.apiKeyMode?.checked),
apiKey,
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
function loadSettings() {
let settings = {};
try {
settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
} catch (_) {}
if (el.maxViews) el.maxViews.value = settings.maxViews || "";
if (el.allowPreview) el.allowPreview.checked = settings.allowPreview !== false;
if (el.keepFilenames) el.keepFilenames.checked = settings.keepFilenames !== false;
if (el.privateBox) el.privateBox.checked = Boolean(settings.privateBox);
if (el.apiKeyMode) el.apiKeyMode.checked = Boolean(settings.apiKeyMode);
if (el.apiKeyInput) el.apiKeyInput.value = validApiKey(settings.apiKey || "") ? settings.apiKey : "";
syncZipForRetention();
syncApiKeyField();
saveSettings();
}
function syncMenuChecks() {
updateDisabledReasons();
}
function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
}
validateApiKeyField();
}
function validateApiKeyField() {
if (!el.apiKeyInput || !el.apiKeyState) return;
clearTimeout(apiKeyTimer);
const wrapper = el.apiKeyInput.closest(".api-key-field");
wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) {
el.apiKeyState.textContent = "";
return;
}
const value = el.apiKeyInput.value.trim();
if (!value) {
el.apiKeyState.textContent = "waiting";
saveSettings();
return;
}
el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => {
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
if (validApiKey(value)) {
el.apiKeyState.textContent = "saved locally";
saveSettings();
} else {
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
}
}, 650);
}
function validApiKey(value) {
return /^[A-Za-z0-9._-]{12,}$/.test(String(value || "").trim());
}
function slugify(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 32);
}
function sanitizeSlugInput(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.slice(0, 32);
}
function syncSlugFromName(force = false) {
if (!el.customSlug || !el.boxName) return;
if (force || !el.customSlug.value || el.customSlug.dataset.auto === "true") {
el.customSlug.value = slugify(el.boxName.value);
el.customSlug.dataset.auto = "true";
}
saveSettings();
updateTerminal();
}
function randomPassword() {
if (!el.password || uploadLocked) return;
el.password.value = `${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
saveSettings();
updateTerminal();
setStatus("Generated a password");
}
function randomBoxName() {
if (!el.boxName || uploadLocked) return;
const adjectives = ["Neon", "Turbo", "Quiet", "Cosmic", "Lucky", "Midnight", "Pixel", "Rapid"];
const nouns = ["Floppy Disk", "Archive Box", "Packet Portal", "Upload Folder", "Cache Drive", "Release Bundle"];
el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`;
syncSlugFromName(true);
setStatus("Generated a local box name");
}

View File

@@ -0,0 +1,88 @@
async function copyText(kind, value, openUrl = "") {
if (!value) {
showToast(`No ${kind.toLowerCase()} yet.`, "warning");
return;
}
try {
await navigator.clipboard.writeText(value);
showToast(`${kind} copied to clipboard.`);
setStatus(`Copied ${kind.toLowerCase()}`);
} catch (_) {
showCopyFallback(kind, value, openUrl);
}
}
function showCopyFallback(kind, value, openUrl) {
const openLink = openUrl ? `<a class="win98-button" href="${htmlEscape(openUrl)}" target="_blank" rel="noreferrer">Open</a>` : "";
showTemplatePopup(`${kind} copy failed`, "copy-failed", {
value: htmlEscape(value),
openLink,
});
}
function quotaWarningHtml(message) {
const tooLarge = oversizedFiles();
const parts = [];
if (tooLarge.length) {
parts.push("<p class=\"quota-dialog-summary\"><strong>Single-file limit exceeded.</strong> Remove these files before uploading.</p>");
parts.push(`<ol class="quota-dialog-list">${tooLarge.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}</span></li>`).join("")}</ol>`);
}
if (isOverBoxQuota()) {
parts.push(`<p class="quota-dialog-summary"><strong>Box quota exceeded.</strong> Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.</p>`);
}
if (!parts.length) parts.push(`<p>${htmlEscape(message)}</p>`);
return parts.join("");
}
function showWarningDialog(title, message) {
showTemplatePopup(title, "warning", {
title: htmlEscape(title),
content: quotaWarningHtml(message),
});
}
function openPopup(title, html, about = false) {
window.WarpBoxUI.openPopup(title, html, {
about,
popup: el.docPopup,
title: el.docPopupTitle,
body: el.docPopupBody,
backdrop: el.modalBackdrop,
});
}
function closeDoc() {
window.WarpBoxUI.closePopup({ popup: el.docPopup, backdrop: el.modalBackdrop });
}
async function showTemplatePopup(title, templateName, data = {}, about = false) {
try {
const html = await window.WBPopups.renderTemplate(templateName, data);
openPopup(title, html, about);
} catch (error) {
showToast(error.message || `Could not load ${title}.`, "error");
}
}
function popupTemplateData(name) {
const data = { origin: window.location.origin };
if (name !== "dailyQuota") return data;
return {
...data,
boxLimit: maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit",
boxPercent: maxBoxBytes ? Math.min(100, Math.round((totalBytes() / maxBoxBytes) * 100)) : 0,
fileLimit: maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit",
filePercent: oversizedFiles().length ? 100 : 0,
};
}
async function openDoc(name) {
try {
const doc = await window.WBPopups.renderDoc(name, popupTemplateData(name));
if (!doc) return;
openPopup(doc.title, doc.html, doc.about);
setStatus(`${doc.title} opened`);
} catch (error) {
showToast(error.message || "Could not load help window.", "error");
}
}

160
static/js/upload/state.js Normal file
View File

@@ -0,0 +1,160 @@
const SETTINGS_KEY = "warpbox.upload.settings.v1";
const el = {
form: document.querySelector("#upload-form"),
fileInput: document.querySelector("#file-upload"),
dropSurface: document.querySelector("#drop-surface"),
dropzone: document.querySelector("#dropzone"),
fileList: document.querySelector("#file-list"),
queueLabel: document.querySelector("#queue-label"),
queueSize: document.querySelector("#queue-size"),
limitHint: document.querySelector("#limit-hint"),
boxSpaceText: document.querySelector("#box-space-text"),
boxSpaceBar: document.querySelector("#box-space-bar"),
overallBar: document.querySelector("#overall-bar"),
overallPercent: document.querySelector("#overall-percent"),
shareLink: document.querySelector("#share-link"),
copyButton: document.querySelector("#copy-button"),
startButton: document.querySelector("#start-button"),
statusText: document.querySelector("#status-text"),
toast: document.querySelector("#toast"),
terminal: document.querySelector("#terminal-box"),
copyCurlButton: document.querySelector("#copy-curl-button"),
docPopup: document.querySelector("#doc-popup"),
modalBackdrop: document.querySelector("#modal-backdrop"),
docPopupTitle: document.querySelector("#doc-popup-title"),
docPopupBody: document.querySelector("#doc-popup-body"),
docPopupClose: document.querySelector("#doc-popup-close"),
expiry: document.querySelector("#expiry-select"),
password: document.querySelector("#password-input"),
optionsForm: document.querySelector("#box-options-form"),
maxViews: document.querySelector("#max-views"),
boxName: document.querySelector("#box-name"),
customSlug: document.querySelector("#custom-slug"),
downloadPage: document.querySelector("#download-page"),
allowZip: document.querySelector("#allow-zip"),
allowPreview: document.querySelector("#allow-preview"),
keepFilenames: document.querySelector("#keep-filenames"),
privateBox: document.querySelector("#private-box"),
apiKeyMode: document.querySelector("#api-key-mode"),
apiKeyInput: document.querySelector("#api-key-input"),
apiKeyRow: document.querySelector("#api-key-row"),
apiKeyState: document.querySelector("#api-key-state"),
};
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time";
let files = [];
let shareUrl = "";
let uploadLocked = false;
let statusTimer = null;
let pendingDuplicateFiles = [];
let apiKeyTimer = null;
let completedImpactKeys = new Set();
let overallImpactDone = false;
function numberFromDataset(value) {
const number = Number.parseInt(value || "0", 10);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
const htmlEscape = window.WarpBoxUI.htmlEscape;
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\\''")}'`;
}
function totalBytes() {
return files.reduce((sum, item) => sum + item.file.size, 0);
}
function uploadedBytes() {
return files.reduce((sum, item) => sum + item.loaded, 0);
}
function overallProgress() {
const total = totalBytes();
return total ? Math.round((uploadedBytes() / total) * 100) : 0;
}
function oversizedFiles() {
return maxFileBytes ? files.filter((item) => item.file.size > maxFileBytes) : [];
}
function isOverBoxQuota() {
return maxBoxBytes ? totalBytes() > maxBoxBytes : false;
}
function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0;
}
function normalizedFileName(name) {
return String(name || "").trim().toLowerCase();
}
function splitNameForIncrement(name) {
const value = String(name || "file");
const dot = value.lastIndexOf(".");
if (dot > 0 && dot < value.length - 1) return [value.slice(0, dot), value.slice(dot)];
return [value, ""];
}
function nextIncrementedFileName(name, usedNames) {
const [base, ext] = splitNameForIncrement(name);
let index = 2;
let candidate = `${base} (${index})${ext}`;
while (usedNames.has(normalizedFileName(candidate))) {
index += 1;
candidate = `${base} (${index})${ext}`;
}
usedNames.add(normalizedFileName(candidate));
return candidate;
}
function makeQueuedFile(file, displayName = file.name) {
return {
file,
displayName,
loaded: 0,
uploaded: false,
failed: false,
error: "",
row: null,
boxID: "",
boxFile: null,
previewURL: file.type?.startsWith("image/") ? URL.createObjectURL(file) : "",
};
}
function iconForFile(file) {
const filename = file.name || "";
const mimeType = file.type || "";
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
if (extension === ".exe") return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png";
if (mimeType.startsWith("image/")) return "/static/img/sprites/bitmap.png";
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) return "/static/img/icons/netshow_notransm-1.png";
if (mimeType.startsWith("text/") || extension === ".md") return "/static/img/sprites/notepad_file-1.png";
if (mimeType.includes("zip") || mimeType.includes("compressed") || [".rar", ".7z", ".tar", ".gz"].includes(extension)) return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png";
if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) return "/static/img/sprites/font.png";
if (extension === ".pdf") return "/static/img/sprites/journal.png";
if ([".html", ".css", ".js"].includes(extension)) return "/static/img/sprites/frame_web-0.png";
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png";
}

View File

@@ -0,0 +1,22 @@
function getCurlCommand({ full = true } = {}) {
const args = [];
const selectedFiles = files.length ? files : [{ displayName: "build.zip" }];
const previewLimit = full ? selectedFiles.length : 4;
selectedFiles.slice(0, previewLimit).forEach((item) => args.push(` -F ${shellQuote(`files=@${item.displayName}`)}`));
const hiddenFileCount = !full && selectedFiles.length > previewLimit ? selectedFiles.length - previewLimit : 0;
args.push(` -F ${shellQuote(`retention=${el.expiry?.value || defaultRetention}`)}`);
if (el.password?.value) args.push(` -F ${shellQuote("password=YOUR_PASSWORD")}`);
if (el.allowZip && !el.allowZip.checked) args.push(` -F ${shellQuote("allow_zip=false")}`);
const commandLines = ["curl"];
if (el.apiKeyMode?.checked) commandLines.push(` -H ${shellQuote("Authorization: Bearer YOUR_API_KEY")}`);
commandLines.push(...args, ` ${window.location.origin}/upload`);
const command = commandLines.join(" \\\n");
return hiddenFileCount ? `${command}\n# and ${hiddenFileCount} other files included when copying` : command;
}
function updateTerminal() {
if (!el.terminal) return;
const command = getCurlCommand({ full: false });
el.terminal.innerHTML = `<span class="terminal-muted">warpbox@cli</span>:~$ ${htmlEscape(command)}`;
}

View File

@@ -0,0 +1,85 @@
async function startUpload() {
if (!uploadsEnabled) {
showToast("Guest uploads are disabled.", "warning");
return;
}
if (uploadLocked) {
showToast("Upload already started. Press Clear to create another box.", "warning");
return;
}
if (!files.length) {
showWarningDialog("No files selected", "There are no files selected. Please select files to upload.");
showToast("No files selected. Please select files to upload.", "warning");
setStatus("No files selected");
return;
}
if (hasQuotaError()) {
showWarningDialog("Over maximum upload size", quotaWarningMessage() || "Over maximum upload size.");
showToast("Over maximum upload size.", "error");
return;
}
uploadLocked = true;
setBoxOptionsLocked(true);
if (el.fileInput) el.fileInput.disabled = true;
el.dropzone?.classList.add("is-locked");
setShareUrl("");
files.forEach((item) => {
item.loaded = 0;
item.uploaded = false;
item.failed = false;
item.error = "";
});
completedImpactKeys = new Set();
overallImpactDone = false;
renderFiles();
let completedCount = 0;
const totalCount = files.length;
const statusPrefix = () => `${completedCount}/${totalCount}`;
setStatus(`${statusPrefix()} Uploading.`);
animateUploadStatus(statusPrefix);
try {
const box = await createBox();
setShareUrl(box.box_url);
files.forEach((item, index) => {
item.boxID = box.box_id;
item.boxFile = box.files[index];
item.displayName = item.boxFile?.name || item.displayName;
const icon = item.row?.querySelector(".upload-file-icon");
if (icon && item.boxFile?.thumbnail_path) {
item.row.classList.add("has-thumbnail");
icon.src = item.boxFile.thumbnail_path;
} else if (icon && item.boxFile?.icon_path && !item.previewURL) {
icon.src = item.boxFile.icon_path;
}
});
const results = await Promise.allSettled(files.map((item) => uploadFile(item, () => { completedCount += 1; })));
stopStatusAnimation();
const failedCount = results.filter((result) => result.status === "rejected").length;
if (failedCount > 0) {
setStatus(`${completedCount}/${totalCount} uploaded, ${failedCount} failed`);
showToast(`${failedCount} file${failedCount === 1 ? "" : "s"} failed. The share URL contains the successful files.`, "error");
renderFiles();
return;
}
setOverallProgress(100);
setStatus(`${completedCount}/${totalCount} uploaded. Share URL created. Press Clear to start another upload.`);
showToast("Upload complete. Share URL created.");
renderFiles();
} catch (error) {
stopStatusAnimation();
uploadLocked = false;
setBoxOptionsLocked(false);
if (el.fileInput) el.fileInput.disabled = !uploadsEnabled;
el.dropzone?.classList.remove("is-locked");
setShareUrl("");
setStatus(error.message || "Upload failed");
showToast(error.message || "Upload failed", "error");
renderFiles();
}
}

View File

@@ -32,17 +32,26 @@ window.WarpBoxUI = (() => {
parts.backdrop?.classList.add("is-visible"); parts.backdrop?.classList.add("is-visible");
} }
function closePopup(options = {}) { function closePopup(options = {}) {
const parts = popupElements(options); const parts = popupElements(options);
parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup"); parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup");
parts.backdrop?.classList.remove("is-visible"); parts.backdrop?.classList.remove("is-visible");
} }
function renderTemplate(template, data = {}) { function htmlEscape(value) {
return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { return String(value || "")
return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : ""; .replaceAll("&", "&amp;")
}); .replaceAll("<", "&lt;")
} .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
return { toast, openPopup, closePopup, renderTemplate }; function renderTemplate(template, data = {}) {
return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : "";
});
}
return { toast, openPopup, closePopup, htmlEscape, renderTemplate };
})(); })();

View File

@@ -19,14 +19,7 @@
</div> </div>
</header> </header>
<div class="win98-panel admin-panel"> <div class="win98-panel admin-panel">
<nav class="admin-nav"> {{ template "admin_nav" . }}
<a class="win98-button" href="/admin">Admin</a>
<a class="win98-button" href="/admin/users">Users</a>
<a class="win98-button" href="/admin/tags">Tags</a>
<a class="win98-button" href="/admin/settings">Settings</a>
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
<div class="admin-summary"> <div class="admin-summary">
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span> <span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
<span class="win98-panel">Storage: {{ .TotalStorage }}</span> <span class="win98-panel">Storage: {{ .TotalStorage }}</span>

11
templates/admin_nav.html Normal file
View File

@@ -0,0 +1,11 @@
{{ define "admin_nav" }}
<nav class="admin-nav">
{{ if ne .AdminSection "dashboard" }}<a class="win98-button" href="/admin">Admin</a>{{ end }}
{{ if ne .AdminSection "boxes" }}<a class="win98-button" href="/admin/boxes">Boxes</a>{{ end }}
{{ if ne .AdminSection "users" }}<a class="win98-button" href="/admin/users">Users</a>{{ end }}
{{ if ne .AdminSection "tags" }}<a class="win98-button" href="/admin/tags">Tags</a>{{ end }}
{{ if ne .AdminSection "settings" }}<a class="win98-button" href="/admin/settings">Settings</a>{{ end }}
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ end }}

View File

@@ -19,14 +19,7 @@
</div> </div>
</header> </header>
<div class="win98-panel admin-panel"> <div class="win98-panel admin-panel">
<nav class="admin-nav"> {{ template "admin_nav" . }}
<a class="win98-button" href="/admin">Admin</a>
<a class="win98-button" href="/admin/boxes">Boxes</a>
<a class="win98-button" href="/admin/users">Users</a>
<a class="win98-button" href="/admin/tags">Tags</a>
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}

View File

@@ -19,14 +19,7 @@
</div> </div>
</header> </header>
<div class="win98-panel admin-panel"> <div class="win98-panel admin-panel">
<nav class="admin-nav"> {{ template "admin_nav" . }}
<a class="win98-button" href="/admin">Admin</a>
<a class="win98-button" href="/admin/boxes">Boxes</a>
<a class="win98-button" href="/admin/users">Users</a>
<a class="win98-button" href="/admin/settings">Settings</a>
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}

View File

@@ -19,14 +19,7 @@
</div> </div>
</header> </header>
<div class="win98-panel admin-panel"> <div class="win98-panel admin-panel">
<nav class="admin-nav"> {{ template "admin_nav" . }}
<a class="win98-button" href="/admin">Admin</a>
<a class="win98-button" href="/admin/boxes">Boxes</a>
<a class="win98-button" href="/admin/tags">Tags</a>
<a class="win98-button" href="/admin/settings">Settings</a>
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ if .Error }} {{ if .Error }}
<p class="admin-error">{{ .Error }}</p> <p class="admin-error">{{ .Error }}</p>
{{ end }} {{ end }}

View File

@@ -7,7 +7,19 @@
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png"> <link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css"> <link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/upload.css"> <link rel="stylesheet" href="/static/css/upload/layout.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/upload/panel.css">
<link rel="stylesheet" href="/static/css/upload/queue.css">
<link rel="stylesheet" href="/static/css/upload/actions.css">
<link rel="stylesheet" href="/static/css/upload/sidebar.css">
<link rel="stylesheet" href="/static/css/upload/options.css">
<link rel="stylesheet" href="/static/css/upload/terminal.css">
<link rel="stylesheet" href="/static/css/upload/folders.css">
<link rel="stylesheet" href="/static/css/upload/dialogs.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/upload/dialog-content.css">
<link rel="stylesheet" href="/static/css/upload/responsive.css">
</head> </head>
<body> <body>
@@ -238,6 +250,15 @@
<script src="/static/js/warpbox-ui.js"></script> <script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/upload-utils.js"></script> <script src="/static/js/upload-utils.js"></script>
<script src="/static/js/upload-popups.js"></script> <script src="/static/js/upload-popups.js"></script>
<script src="/static/js/upload/state.js"></script>
<script src="/static/js/upload/dom.js"></script>
<script src="/static/js/upload/files.js"></script>
<script src="/static/js/upload/api.js"></script>
<script src="/static/js/upload/upload-flow.js"></script>
<script src="/static/js/upload/options.js"></script>
<script src="/static/js/upload/terminal.js"></script>
<script src="/static/js/upload/popups.js"></script>
<script src="/static/js/upload/events.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html> </html>