diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..53575e9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3094270 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..b055851 --- /dev/null +++ b/DEVELOPMENT.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f178240 --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a846cb4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +WarpBox +Copyright (c) 2026 Daniel Legt + +This product includes software developed by Daniel Legt. \ No newline at end of file diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000..f97efd5 --- /dev/null +++ b/TRADEMARK.md @@ -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. \ No newline at end of file diff --git a/admin-overview.md b/admin-overview.md new file mode 100644 index 0000000..fb9c809 --- /dev/null +++ b/admin-overview.md @@ -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. diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..35614e0 --- /dev/null +++ b/check.sh @@ -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 ./... "$@" diff --git a/lib/boxstore/files.go b/lib/boxstore/files.go new file mode 100644 index 0000000..0d5ffdb --- /dev/null +++ b/lib/boxstore/files.go @@ -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 +} diff --git a/lib/boxstore/icons.go b/lib/boxstore/icons.go new file mode 100644 index 0000000..d4034ad --- /dev/null +++ b/lib/boxstore/icons.go @@ -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" + } +} diff --git a/lib/boxstore/manifest.go b/lib/boxstore/manifest.go new file mode 100644 index 0000000..e85f7bd --- /dev/null +++ b/lib/boxstore/manifest.go @@ -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) +} diff --git a/lib/boxstore/paths.go b/lib/boxstore/paths.go new file mode 100644 index 0000000..125e1f2 --- /dev/null +++ b/lib/boxstore/paths.go @@ -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 +} diff --git a/lib/boxstore/retention.go b/lib/boxstore/retention.go new file mode 100644 index 0000000..7d2e2bc --- /dev/null +++ b/lib/boxstore/retention.go @@ -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) +} diff --git a/lib/boxstore/security.go b/lib/boxstore/security.go new file mode 100644 index 0000000..24543c6 --- /dev/null +++ b/lib/boxstore/security.go @@ -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[:]) +} diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go deleted file mode 100644 index ede13ef..0000000 --- a/lib/boxstore/store.go +++ /dev/null @@ -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 -} diff --git a/lib/boxstore/summary.go b/lib/boxstore/summary.go new file mode 100644 index 0000000..742e5f0 --- /dev/null +++ b/lib/boxstore/summary.go @@ -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 +} diff --git a/lib/boxstore/zip.go b/lib/boxstore/zip.go new file mode 100644 index 0000000..ec55e89 --- /dev/null +++ b/lib/boxstore/zip.go @@ -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 +} diff --git a/lib/config/config.go b/lib/config/config.go deleted file mode 100644 index 45de2b7..0000000 --- a/lib/config/config.go +++ /dev/null @@ -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" -} diff --git a/lib/config/definitions.go b/lib/config/definitions.go new file mode 100644 index 0000000..46f4ff9 --- /dev/null +++ b/lib/config/definitions.go @@ -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 +} diff --git a/lib/config/load.go b/lib/config/load.go new file mode 100644 index 0000000..3859d3d --- /dev/null +++ b/lib/config/load.go @@ -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 +} diff --git a/lib/config/models.go b/lib/config/models.go new file mode 100644 index 0000000..dcfb68f --- /dev/null +++ b/lib/config/models.go @@ -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 +} diff --git a/lib/config/overrides.go b/lib/config/overrides.go new file mode 100644 index 0000000..5e4395a --- /dev/null +++ b/lib/config/overrides.go @@ -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 +} diff --git a/lib/config/parse.go b/lib/config/parse.go new file mode 100644 index 0000000..8add7b2 --- /dev/null +++ b/lib/config/parse.go @@ -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" +} diff --git a/lib/server/admin.go b/lib/server/admin.go deleted file mode 100644 index 7dc6233..0000000 --- a/lib/server/admin.go +++ /dev/null @@ -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") -} diff --git a/lib/server/admin_auth.go b/lib/server/admin_auth.go new file mode 100644 index 0000000..c787d79 --- /dev/null +++ b/lib/server/admin_auth.go @@ -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 +} diff --git a/lib/server/admin_boxes.go b/lib/server/admin_boxes.go new file mode 100644 index 0000000..e73eecd --- /dev/null +++ b/lib/server/admin_boxes.go @@ -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, + }) +} diff --git a/lib/server/admin_dashboard.go b/lib/server/admin_dashboard.go new file mode 100644 index 0000000..da37874 --- /dev/null +++ b/lib/server/admin_dashboard.go @@ -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), + }) +} diff --git a/lib/server/admin_format.go b/lib/server/admin_format.go new file mode 100644 index 0000000..a3314cd --- /dev/null +++ b/lib/server/admin_format.go @@ -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") +} diff --git a/lib/server/admin_routes.go b/lib/server/admin_routes.go new file mode 100644 index 0000000..29e31a4 --- /dev/null +++ b/lib/server/admin_routes.go @@ -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) +} diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go new file mode 100644 index 0000000..cb82912 --- /dev/null +++ b/lib/server/admin_settings.go @@ -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, + }) +} diff --git a/lib/server/admin_tags.go b/lib/server/admin_tags.go new file mode 100644 index 0000000..2367691 --- /dev/null +++ b/lib/server/admin_tags.go @@ -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" +} diff --git a/lib/server/admin_users.go b/lib/server/admin_users.go new file mode 100644 index 0000000..1a9f4f1 --- /dev/null +++ b/lib/server/admin_users.go @@ -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, + }) +} diff --git a/lib/server/box_auth.go b/lib/server/box_auth.go new file mode 100644 index 0000000..c7d7216 --- /dev/null +++ b/lib/server/box_auth.go @@ -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, + }) +} diff --git a/lib/server/downloads.go b/lib/server/downloads.go new file mode 100644 index 0000000..9bdbea9 --- /dev/null +++ b/lib/server/downloads.go @@ -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) +} diff --git a/lib/server/handlers.go b/lib/server/handlers.go deleted file mode 100644 index f49804a..0000000 --- a/lib/server/handlers.go +++ /dev/null @@ -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, - }) -} diff --git a/lib/server/pages.go b/lib/server/pages.go new file mode 100644 index 0000000..99bd850 --- /dev/null +++ b/lib/server/pages.go @@ -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}) +} diff --git a/lib/server/retention.go b/lib/server/retention.go new file mode 100644 index 0000000..b73e8ff --- /dev/null +++ b/lib/server/retention.go @@ -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] +} diff --git a/lib/server/uploads.go b/lib/server/uploads.go new file mode 100644 index 0000000..33f1b2e --- /dev/null +++ b/lib/server/uploads.go @@ -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}) +} diff --git a/lib/server/validation.go b/lib/server/validation.go new file mode 100644 index 0000000..1aa41bd --- /dev/null +++ b/lib/server/validation.go @@ -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 +} diff --git a/static/css/components/buttons.css b/static/css/components/buttons.css new file mode 100644 index 0000000..bb2f6b0 --- /dev/null +++ b/static/css/components/buttons.css @@ -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; +} diff --git a/static/css/components/toast.css b/static/css/components/toast.css new file mode 100644 index 0000000..01edaa1 --- /dev/null +++ b/static/css/components/toast.css @@ -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; +} diff --git a/static/css/upload.css b/static/css/upload.css deleted file mode 100644 index 09f8b3c..0000000 --- a/static/css/upload.css +++ /dev/null @@ -1,1219 +0,0 @@ -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -.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; -} - -@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; } -} diff --git a/static/css/upload/actions.css b/static/css/upload/actions.css new file mode 100644 index 0000000..ca7d5d5 --- /dev/null +++ b/static/css/upload/actions.css @@ -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; +} diff --git a/static/css/upload/dialog-content.css b/static/css/upload/dialog-content.css new file mode 100644 index 0000000..d3a4fb9 --- /dev/null +++ b/static/css/upload/dialog-content.css @@ -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; +} diff --git a/static/css/upload/dialogs.css b/static/css/upload/dialogs.css new file mode 100644 index 0000000..5d5c6f5 --- /dev/null +++ b/static/css/upload/dialogs.css @@ -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; +} + diff --git a/static/css/upload/folders.css b/static/css/upload/folders.css new file mode 100644 index 0000000..491417a --- /dev/null +++ b/static/css/upload/folders.css @@ -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; +} diff --git a/static/css/upload/layout.css b/static/css/upload/layout.css new file mode 100644 index 0000000..f40214a --- /dev/null +++ b/static/css/upload/layout.css @@ -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; +} + diff --git a/static/css/upload/options.css b/static/css/upload/options.css new file mode 100644 index 0000000..56cc53c --- /dev/null +++ b/static/css/upload/options.css @@ -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; +} + diff --git a/static/css/upload/panel.css b/static/css/upload/panel.css new file mode 100644 index 0000000..d698eb9 --- /dev/null +++ b/static/css/upload/panel.css @@ -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; +} diff --git a/static/css/upload/queue.css b/static/css/upload/queue.css new file mode 100644 index 0000000..9efc87d --- /dev/null +++ b/static/css/upload/queue.css @@ -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; +} diff --git a/static/css/upload/responsive.css b/static/css/upload/responsive.css new file mode 100644 index 0000000..cb5278f --- /dev/null +++ b/static/css/upload/responsive.css @@ -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; } +} diff --git a/static/css/upload/sidebar.css b/static/css/upload/sidebar.css new file mode 100644 index 0000000..ea241c4 --- /dev/null +++ b/static/css/upload/sidebar.css @@ -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; +} diff --git a/static/css/upload/terminal.css b/static/css/upload/terminal.css new file mode 100644 index 0000000..cb6b9b1 --- /dev/null +++ b/static/css/upload/terminal.css @@ -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; +} diff --git a/static/js/app.js b/static/js/app.js index d27d620..61fd250 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,1268 +1,3 @@ -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]}`; -} - -function htmlEscape(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -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"; -} - -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(); -} - -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) => `
  • ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)}
  • `).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()); -} - -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); - }); -} - -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(); - } -} - -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"); -} - -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 = `warpbox@cli:~$ ${htmlEscape(command)}`; -} - -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 ? `Open` : ""; - showTemplatePopup(`${kind} copy failed`, "copy-failed", { - value: htmlEscape(value), - openLink, - }); -} - -function quotaWarningHtml(message) { - const tooLarge = oversizedFiles(); - const parts = []; - if (tooLarge.length) { - parts.push("

    Single-file limit exceeded. Remove these files before uploading.

    "); - parts.push(`
      ${tooLarge.map((item) => `
    1. ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}
    2. `).join("")}
    `); - } - if (isOverBoxQuota()) { - parts.push(`

    Box quota exceeded. Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.

    `); - } - if (!parts.length) parts.push(`

    ${htmlEscape(message)}

    `); - 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"); - } -} - -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(); - if (action === "browse") el.fileInput?.click(); - if (action === "start-upload") startUpload(); - if (action === "copy-link") copyText("Share URL", shareUrl, shareUrl); - if (action === "clear") confirmClearQueue(); - if (action === "toggle-delete-once" && el.expiry?.querySelector(`option[value="${oneTimeRetentionKey}"]`)) { - el.expiry.value = isOneTimeDownloadSelected() ? defaultRetention : oneTimeRetentionKey; - syncZipForRetention(); - saveSettings(); - syncMenuChecks(); - updateTerminal(); - } - if (action === "random-password") randomPassword(); - if (action === "random-box-name") randomBoxName(); - if (action === "clear-password" && el.password && !uploadLocked) { - el.password.value = ""; - saveSettings(); - updateTerminal(); - } - if (action === "toggle-page" && el.downloadPage && !uploadLocked) { - el.downloadPage.checked = !el.downloadPage.checked; - saveSettings(); - syncMenuChecks(); - } - if (action === "help" || action === "side-help") openDoc("faq"); - if (action === "coming-soon") showToast("Coming Soon, not implemented just yet."); - if (action === "fake-close") showToast("Close button denied. The upload window is staying open.", "warning"); - if (action === "minimize") showToast("Minimize requested. WarpBox stays visible so your queue is safe."); - if (action === "toggle-fit") { - document.body.classList.toggle("fit-window"); - showToast("Maximize requested. The pixel rectangle feels important now."); - } - if (action === "side-close") showToast("Box Options refuses to leave. Settings stay visible."); - if (action === "side-help") showToast("Terminal help opened. Copy the command and feed it files."); - if (action === "side-folder-close") showToast("The folder window saw that click and chose denial."); - 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); - }); -}); - loadSettings(); updateLimitHint(); syncMenuChecks(); diff --git a/static/js/box.js b/static/js/box.js index 5ef65cb..460ef1b 100644 --- a/static/js/box.js +++ b/static/js/box.js @@ -15,14 +15,7 @@ const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true"; let contextFile = null; let lastStatusSignature = ""; -function htmlEscape(value) { - return String(value || "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} +const htmlEscape = window.WarpBoxUI.htmlEscape; function showToast(message, type = "info") { window.WarpBoxUI.toast(message, type, { target: toast }); @@ -302,23 +295,39 @@ function startStagedPolling(baseMS) { 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) => { 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 === "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."); - } + if (action) runBoxAction(action); const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction; if (contextAction && contextFile) { event.preventDefault(); const item = contextFile; closeContextMenu(); - if (contextAction === "preview") previewFile(item); - if (contextAction === "download") downloadFile(item); - if (contextAction === "properties") showProperties(item); + runContextAction(contextAction, item); return; } diff --git a/static/js/upload/api.js b/static/js/upload/api.js new file mode 100644 index 0000000..56ac531 --- /dev/null +++ b/static/js/upload/api.js @@ -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); + }); +} diff --git a/static/js/upload/dom.js b/static/js/upload/dom.js new file mode 100644 index 0000000..5f284d2 --- /dev/null +++ b/static/js/upload/dom.js @@ -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(); +} diff --git a/static/js/upload/events.js b/static/js/upload/events.js new file mode 100644 index 0000000..d89d164 --- /dev/null +++ b/static/js/upload/events.js @@ -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); + }); +}); diff --git a/static/js/upload/files.js b/static/js/upload/files.js new file mode 100644 index 0000000..4ff70dc --- /dev/null +++ b/static/js/upload/files.js @@ -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) => `
  • ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)}
  • `).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()); +} diff --git a/static/js/upload/options.js b/static/js/upload/options.js new file mode 100644 index 0000000..504c775 --- /dev/null +++ b/static/js/upload/options.js @@ -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"); +} diff --git a/static/js/upload/popups.js b/static/js/upload/popups.js new file mode 100644 index 0000000..a9cac72 --- /dev/null +++ b/static/js/upload/popups.js @@ -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 ? `Open` : ""; + showTemplatePopup(`${kind} copy failed`, "copy-failed", { + value: htmlEscape(value), + openLink, + }); +} + +function quotaWarningHtml(message) { + const tooLarge = oversizedFiles(); + const parts = []; + if (tooLarge.length) { + parts.push("

    Single-file limit exceeded. Remove these files before uploading.

    "); + parts.push(`
      ${tooLarge.map((item) => `
    1. ${htmlEscape(item.displayName)} ${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}
    2. `).join("")}
    `); + } + if (isOverBoxQuota()) { + parts.push(`

    Box quota exceeded. Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.

    `); + } + if (!parts.length) parts.push(`

    ${htmlEscape(message)}

    `); + 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"); + } +} diff --git a/static/js/upload/state.js b/static/js/upload/state.js new file mode 100644 index 0000000..fdffb0b --- /dev/null +++ b/static/js/upload/state.js @@ -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"; +} diff --git a/static/js/upload/terminal.js b/static/js/upload/terminal.js new file mode 100644 index 0000000..14e1b33 --- /dev/null +++ b/static/js/upload/terminal.js @@ -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 = `warpbox@cli:~$ ${htmlEscape(command)}`; +} diff --git a/static/js/upload/upload-flow.js b/static/js/upload/upload-flow.js new file mode 100644 index 0000000..1695ceb --- /dev/null +++ b/static/js/upload/upload-flow.js @@ -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(); + } +} diff --git a/static/js/warpbox-ui.js b/static/js/warpbox-ui.js index 172f248..d43ae25 100644 --- a/static/js/warpbox-ui.js +++ b/static/js/warpbox-ui.js @@ -32,17 +32,26 @@ window.WarpBoxUI = (() => { parts.backdrop?.classList.add("is-visible"); } - function closePopup(options = {}) { - const parts = popupElements(options); - parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup"); - parts.backdrop?.classList.remove("is-visible"); - } +function closePopup(options = {}) { + const parts = popupElements(options); + parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup"); + parts.backdrop?.classList.remove("is-visible"); +} - 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]) : ""; - }); - } +function htmlEscape(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} - 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 }; })(); diff --git a/templates/admin_boxes.html b/templates/admin_boxes.html index 7cbbb2e..a8f97cd 100644 --- a/templates/admin_boxes.html +++ b/templates/admin_boxes.html @@ -19,14 +19,7 @@
    - + {{ template "admin_nav" . }}
    Boxes: {{ .TotalBoxes }} Storage: {{ .TotalStorage }} diff --git a/templates/admin_nav.html b/templates/admin_nav.html new file mode 100644 index 0000000..1b8985a --- /dev/null +++ b/templates/admin_nav.html @@ -0,0 +1,11 @@ +{{ define "admin_nav" }} + +{{ end }} diff --git a/templates/admin_settings.html b/templates/admin_settings.html index be23b73..59b3c87 100644 --- a/templates/admin_settings.html +++ b/templates/admin_settings.html @@ -19,14 +19,7 @@
    - + {{ template "admin_nav" . }} {{ if .Error }}

    {{ .Error }}

    {{ end }} diff --git a/templates/admin_tags.html b/templates/admin_tags.html index 126077b..aa008a4 100644 --- a/templates/admin_tags.html +++ b/templates/admin_tags.html @@ -19,14 +19,7 @@
    - + {{ template "admin_nav" . }} {{ if .Error }}

    {{ .Error }}

    {{ end }} diff --git a/templates/admin_users.html b/templates/admin_users.html index 80ba4db..f32525e 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -19,14 +19,7 @@
    - + {{ template "admin_nav" . }} {{ if .Error }}

    {{ .Error }}

    {{ end }} diff --git a/templates/index.html b/templates/index.html index 1ad4b7c..f4dc05f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,7 +7,19 @@ - + + + + + + + + + + + + + @@ -238,6 +250,15 @@ + + + + + + + + +