22 Commits

Author SHA1 Message Date
fc54f7bb86 fix(ci/cd): Naming fix
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
2026-05-01 03:41:32 +03:00
42030003d3 feat(ci/cd): Implemented simple package publishing to registry
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 1m1s
2026-05-01 03:39:33 +03:00
25bc095412 feat(docker): add healthcheck and wget dependency
Adds a healthcheck endpoint and updates Dockerfile/README
to include wget and define healthcheck logic.
2026-05-01 03:31:01 +03:00
54bb68642f chore(docker): update build path and volume comment 2026-05-01 02:58:16 +03:00
9b57b2a535 feat(routing): add user admin panel support
Adds the user administration route, associated server handlers, and frontend assets for managing user accounts.
2026-05-01 02:34:47 +03:00
1cf38d126d refactor(storage): standardize size limits to use GB units 2026-05-01 02:14:05 +03:00
d0aa86205f feat(setting): Implemented the settings administrative menu 2026-05-01 01:51:06 +03:00
36d49a970e feat(admin): add full alerts dashboard functionality 2026-05-01 00:46:10 +03:00
3844473eb3 feat(admin): implement full admin dashboard structure 2026-05-01 00:29:06 +03:00
5f3f63b710 'cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules
Reviewed-on: kato/WarpBox#1
2026-04-30 22:08:12 +03:00
9951cfc8b6 cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules. 2026-04-30 22:06:45 +03:00
b8bb75f7e0 feat(cli): add robust box listing filters and sorting 2026-04-30 12:46:44 +03:00
b0bdf798a9 chore(script): update run script path
Change the go run command to use the
relative path ./cmd instead of the full
package path ./cmd/main.go.
2026-04-30 12:43:19 +03:00
877ac90574 feat(cli): add comprehensive command suite
+ Adds new commands for managing boxes, environment variables, and running the server.
* The command structure is greatly expanded to improve user experience and coverage for core service functionalities.
2026-04-30 11:31:43 +03:00
f0b723e35d refactor(code): Cleaned-up the code base 2026-04-30 11:05:56 +03:00
a729b641b2 feat(one-time-downloads): add expiry and retry configuration
Introduce new environment variables to control the behavior of one-time download boxes:
- `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS`: Sets the lifetime of a one-time box after uploads are complete.
- `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE`: Determines whether a box remains available if the ZIP creation or transfer fails.

To support these settings, the ZIP delivery process was refactored to use a temporary file. This ensures that a one-time box is only marked as consumed after the file has been successfully transferred to the client, preventing data loss on network interruptions.

Additionally, added a `DecorateFiles` helper in the box store to reduce code duplication.
2026-04-30 04:24:49 +03:00
7d70a0c2ed feat(boxstore): add configurable expiry for one-time downloads
Introduces a new configuration setting `one_time_download_expiry_seconds` to allow administrators to define a default expiration period for one-time downloads.

The retention logic in `boxstore` has been updated to use this global expiry value when a box is marked as a one-time download and no specific retention period is defined in the manifest.
2026-04-30 03:54:50 +03:00
6b9f6ac291 feature(docker): Implemented DockerFile, docker compose .env and updated the run.sh file. 2026-04-30 03:12:58 +03:00
0f630b9dca chore(cleanup) 2026-04-30 02:34:05 +03:00
903b4eeed8 docs: update README and tech docs for BadgerDB and thumbnails
- Reflect BadgerDB integration for metadata storage in features and
  architecture diagrams
- Add thumbnail generation and static asset subdirectories to project
  layout
- Document _MB configuration variants for size limits
- List main API request surfaces in docs/tech.md for better developer
  reference
- Align documentation with recent architectural and routing changes
2026-04-30 02:32:45 +03:00
ac6e8c591b feat(ui): add reusable warpbox wrapper and improve upload documentation
Updated static/popups/cli.html with clearer upload instructions, added a reusable warpbox shell wrapper with install steps and a function for printing the share URL, making the command more accessible and portable.
2026-04-29 11:51:04 +03:00
fb80f11e72 fix: bugs in general 2026-04-29 02:35:23 +03:00
126 changed files with 14955 additions and 9570 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitignore
docs/
memory-bank/
*_test.go
README.md
run.sh
Dockerfile
.dockerignore
data/

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Core service switches
WARPBOX_GUEST_UPLOADS_ENABLED=true
WARPBOX_API_ENABLED=true
WARPBOX_ZIP_DOWNLOADS_ENABLED=true
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false
# Storage and expiry limits (in MB)
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800
# Tuning
WARPBOX_BOX_POLL_INTERVAL_MS=5000
WARPBOX_THUMBNAIL_BATCH_SIZE=10
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=30
# Data location
# For local run: ./data
# For Docker: /app/data
WARPBOX_DATA_DIR=./data
# Admin Area
WARPBOX_ADMIN_ENABLED=true
WARPBOX_ADMIN_PASSWORD=123

View File

@@ -0,0 +1,45 @@
name: Build and Publish Docker Image
run-name: Publishing ${{ gitea.ref_name }}
on:
push:
tags:
- "v*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Run Tests
run: go test ./...
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Build Docker Image
run: |
docker build \
-t tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/warpbox:latest \
.
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: tea.chunkbyte.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Push Docker Image
run: |
docker push tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }}
docker push tea.chunkbyte.com/kato/warpbox:latest

22
.gitignore vendored
View File

@@ -1 +1,23 @@
# Data & Env
data/
.env
docker-compose.yml
dev
# Go
bin/
vendor/
*.exe
*.test
*.out
*.prof
# IDEs
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

41
CODE_OF_CONDUCT.md Normal file
View File

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

125
CONTRIBUTING.md Normal file
View File

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

183
DEVELOPMENT.md Normal file
View File

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

72
Dockerfile Normal file
View File

@@ -0,0 +1,72 @@
# Stage 1: Build
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /build
# Copy go modules and download dependencies
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy source code and static assets
COPY cmd/ cmd/
COPY lib/ lib/
COPY static/ static/
COPY templates/ templates/
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add \
--no-cache \
ca-certificates \
tzdata \
wget
# Create non-root user
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/warpbox .
# Copy static assets
COPY --from=builder /build/static/ static/
COPY --from=builder /build/templates/ templates/
# Create data directory
RUN mkdir -p /app/data/uploads /app/data/db && chown -R warpbox:warpbox /app/data
# Switch to non-root user
USER warpbox
# Environment variables
ENV WARPBOX_DATA_DIR=/app/data \
WARPBOX_GUEST_UPLOADS_ENABLED=true \
WARPBOX_API_ENABLED=true \
WARPBOX_ZIP_DOWNLOADS_ENABLED=true \
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
WARPBOX_ADMIN_ENABLED=true \
WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
WARPBOX_THUMBNAIL_BATCH_SIZE=10 \
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=30
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
VOLUME ["/app/data"]
CMD ["./warpbox", "run", "--addr", ":8080"]

190
LICENSE Normal file
View File

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

4
NOTICE Normal file
View File

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

View File

@@ -1,19 +1,30 @@
# WarpBox
WarpBox is a small, self-hosted file sharing app with temporary upload boxes,
simple download links, optional passwords, ZIP downloads, and a very deliberate
retro desktop mood.
simple download links, optional passwords, ZIP downloads, generated image
thumbnails, and a very deliberate retro desktop mood.
It is meant to feel quick: pick files, choose how long the box should live,
upload, and share the link.
```mermaid
flowchart LR
A[Choose files] --> B[Create box]
B --> C[Upload to box]
C --> D[Share link]
D --> E[Download files or ZIP]
E --> F[Expire or one-time cleanup]
User[Person in browser]
UI[WarpBox UI]
API[Go HTTP server]
Manifest[(Box manifest JSON)]
Files[(Uploaded files)]
Thumbs[(Thumbnail JPEGs)]
DB[(BadgerDB metadata)]
User --> UI
UI -->|create box / upload / poll status| API
API --> Manifest
API --> Files
API --> DB
Files -->|download files or build ZIP| API
Thumbs -->|preview URLs| UI
Files -->|scan image files| Thumbs
```
## Features
@@ -25,7 +36,8 @@ flowchart LR
- One-time download mode for ZIP-only handoff.
- Background thumbnails for image files.
- Plain filesystem storage, with JSON manifests next to uploaded files.
- No database required.
- Local BadgerDB metadata store for users, tags, sessions, and settings.
- No external database service required.
## How It Fits Together
@@ -36,11 +48,13 @@ flowchart TB
Manifest[Box manifest JSON]
Files[Uploaded files]
Thumbs[Generated thumbnails]
DB[(BadgerDB metadata)]
Browser -->|create box / upload / poll| Server
Browser -->|POST /box, uploads, status polls| Server
Server --> Manifest
Server --> Files
Server --> Thumbs
Server --> DB
Thumbs -->|preview URLs| Browser
Files -->|downloads / ZIP| Browser
```
@@ -72,8 +86,8 @@ go run ./cmd run --addr :3000
## Configuration
WarpBox loads defaults, applies environment variables at startup, then applies
safe admin settings overrides from BadgerDB. Hard storage and global limit
settings remain environment controlled.
safe admin settings overrides from BadgerDB. Storage path settings remain
environment controlled.
| Variable | Default | What it does |
| --- | ---: | --- |
@@ -88,23 +102,28 @@ settings remain environment controlled.
| `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. |
| `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. |
| `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED` | `true` | Enables one-time download boxes. |
| `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS` | `604800` | One-time box lifetime after uploads finish; `0` disables timed expiry. |
| `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE` | `false` | Keeps one-time boxes alive when ZIP build/send fails before completion. |
| `WARPBOX_RENEW_ON_ACCESS_ENABLED` | `false` | Renews expiring boxes on access. |
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. |
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. |
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. |
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. |
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. |
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
Example:
```bash
WARPBOX_ADMIN_PASSWORD='change-me' \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_BOX_POLL_INTERVAL_MS=2000 \
WARPBOX_THUMBNAIL_BATCH_SIZE=20 \
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \
@@ -153,14 +172,42 @@ lib/metastore/ BadgerDB metadata store for users, tags, settings, sessions
lib/helpers/ Small shared helpers
lib/models/ Shared request/response models
templates/ Server-rendered HTML
static/ CSS, JavaScript, fonts, icons, and images
static/css/ Stylesheets
static/js/ Browser scripts
static/img/ Icons, sprites, and backgrounds
static/fonts/ Bitmap/pixel fonts
static/cursors/ Custom cursor packs
static/popups/ HTML popup content
docs/ Project documentation
```
## Notes
WarpBox is intentionally simple. It uses the local filesystem as its backing
store, relies on generated box IDs for share links, and keeps most behavior
easy to follow from the Go handlers and the small browser scripts.
WarpBox is intentionally simple. It uses the local filesystem for box data,
BadgerDB for app metadata, relies on generated box IDs for share links, and
keeps most behavior easy to follow from the Go handlers and the small browser
scripts.
For a short implementation overview, see [docs/tech.md](docs/tech.md).
## Docker / Podman
If you are using Podman, please pay attention in the [docker-compose.yml](./docker-compose.example.yml) example
file that has been provided, there's comments in regards to differences between the two.
When it comes to building the image, please make sure that you basically set the `--format docker` in the podman
build command, otherwise it won't have HealthChecks and other issues might arise.
Tip: Put the following in `~/.config/containers/containers.conf`
```toml
[engine]
image_default_format = "docker"
```
For just running the docker-compose.yml with docker image format:
```bash
BUILDAH_FORMAT=docker podman compose up --build
```

2
TRADEMARK.md Normal file
View File

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

19
check.sh Executable file
View File

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

554
cmd/cmd_box.go Normal file
View File

@@ -0,0 +1,554 @@
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func newBoxCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "box",
Short: "Manage boxes",
Long: "Manage WarpBox upload boxes: list, view, inspect, delete, modify.",
}
cmd.AddCommand(newBoxListCommand())
cmd.AddCommand(newBoxViewCommand())
cmd.AddCommand(newBoxInspectCommand())
cmd.AddCommand(newBoxDeleteCommand())
cmd.AddCommand(newBoxChangeCommand())
cmd.AddCommand(newBoxGetCommand())
return cmd
}
func newBoxListCommand() *cobra.Command {
var format string
var uploadRoot string
var sortBy string
var sortOrder string
var filterExpired string
var filterPassword string
var filterOneTime string
var filterSizeMin string
var filterSizeMax string
var filterCreatedAfter string
var filterCreatedBefore string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list", "view"},
Short: "List all boxes",
Long: "List all boxes with optional sorting and filtering.",
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
return fmt.Errorf("failed to list boxes: %w", err)
}
if len(summaries) == 0 {
fmt.Println("No boxes found.")
return nil
}
// Apply filters
summaries = filterBoxes(summaries, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore)
if len(summaries) == 0 {
fmt.Println("No boxes match the given filters.")
return nil
}
// Apply sorting
sortBoxes(summaries, sortBy, sortOrder)
switch format {
case "json":
return formatBoxSummariesJSON(summaries)
case "table", "":
return formatBoxSummariesTable(summaries)
default:
return fmt.Errorf("unknown format: %s (use 'table' or 'json')", format)
}
},
}
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json")
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().StringVar(&sortBy, "sort", "created", "Sort field: created, expires, size, files")
cmd.Flags().StringVar(&sortOrder, "sort-order", "desc", "Sort order: asc, desc")
cmd.Flags().StringVar(&filterExpired, "filter-expired", "", "Filter by expiry: yes, no, all")
cmd.Flags().StringVar(&filterPassword, "filter-password", "", "Filter by password: yes, no, all")
cmd.Flags().StringVar(&filterOneTime, "filter-one-time", "", "Filter by one-time: yes, no, all")
cmd.Flags().StringVar(&filterSizeMin, "filter-size-min", "", "Minimum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
cmd.Flags().StringVar(&filterSizeMax, "filter-size-max", "", "Maximum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
cmd.Flags().StringVar(&filterCreatedAfter, "filter-created-after", "", "Only boxes created after this time (RFC3339)")
cmd.Flags().StringVar(&filterCreatedBefore, "filter-created-before", "", "Only boxes created before this time (RFC3339)")
return cmd
}
func filterBoxes(summaries []models.BoxSummary, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore string) []models.BoxSummary {
result := make([]models.BoxSummary, 0, len(summaries))
minSize, _ := parseSizeFilter(filterSizeMin)
maxSize, _ := parseSizeFilter(filterSizeMax)
createdAfter, _ := time.Parse(time.RFC3339, filterCreatedAfter)
createdBefore, _ := time.Parse(time.RFC3339, filterCreatedBefore)
for _, s := range summaries {
if filterExpired != "" && filterExpired != "all" {
match := "no"
if s.Expired {
match = "yes"
}
if match != filterExpired {
continue
}
}
if filterPassword != "" && filterPassword != "all" {
match := "no"
if s.PasswordProtected {
match = "yes"
}
if match != filterPassword {
continue
}
}
if filterOneTime != "" && filterOneTime != "all" {
match := "no"
if s.OneTimeDownload {
match = "yes"
}
if match != filterOneTime {
continue
}
}
if minSize > 0 && s.TotalSize < minSize {
continue
}
if maxSize > 0 && s.TotalSize > maxSize {
continue
}
if !createdAfter.IsZero() && s.CreatedAt.Before(createdAfter) {
continue
}
if !createdBefore.IsZero() && !s.CreatedAt.Before(createdBefore) {
continue
}
result = append(result, s)
}
return result
}
func parseSizeFilter(s string) (int64, error) {
if s == "" {
return 0, nil
}
s = strings.TrimSpace(s)
lower := strings.ToLower(s)
multiplier := int64(1)
switch {
case strings.HasSuffix(lower, "g"):
multiplier = 1024 * 1024 * 1024
s = strings.TrimSuffix(lower, "g")
case strings.HasSuffix(lower, "m"):
multiplier = 1024 * 1024
s = strings.TrimSuffix(lower, "m")
case strings.HasSuffix(lower, "k"):
multiplier = 1024
s = strings.TrimSuffix(lower, "k")
}
val, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid size filter: %s", s)
}
return val * multiplier, nil
}
func sortBoxes(summaries []models.BoxSummary, sortBy, sortOrder string) {
reverse := false
if strings.EqualFold(sortOrder, "desc") {
reverse = true
}
sort.SliceStable(summaries, func(i, j int) bool {
var less bool
switch strings.ToLower(sortBy) {
case "size":
less = summaries[i].TotalSize < summaries[j].TotalSize
case "files":
less = summaries[i].FileCount < summaries[j].FileCount
case "expires":
// Boxes with no expiry go last
iZero := summaries[i].ExpiresAt.IsZero()
jZero := summaries[j].ExpiresAt.IsZero()
if iZero && jZero {
return false
}
if iZero {
return false
}
if jZero {
return true
}
less = summaries[i].ExpiresAt.Before(summaries[j].ExpiresAt)
case "created", "":
less = summaries[i].CreatedAt.Before(summaries[j].CreatedAt)
default:
less = summaries[i].ID < summaries[j].ID
}
if reverse {
return !less
}
return less
})
}
func newBoxViewCommand() *cobra.Command {
var uploadRoot string
var asJSON bool
cmd := &cobra.Command{
Use: "view",
Short: "View box summary",
Long: "View a box summary showing files, size, expiry, etc.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
boxID := args[0]
summary, err := boxstore.BoxSummary(boxID)
if err != nil {
return fmt.Errorf("failed to view box %s: %w", boxID, err)
}
if asJSON {
return formatBoxSummaryJSON(&summary)
}
printBoxSummary(&summary)
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
func newBoxInspectCommand() *cobra.Command {
var uploadRoot string
var full bool
var asJSON bool
cmd := &cobra.Command{
Use: "inspect",
Short: "Inspect box manifest (raw JSON)",
Long: "Print the full box manifest as JSON. Use --full for hidden fields.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
boxID := args[0]
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
}
if !full {
sanitized := manifest
sanitized.PasswordHash = "[REDACTED]"
sanitized.PasswordSalt = "[REDACTED]"
sanitized.AuthToken = "[REDACTED]"
manifest = sanitized
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(manifest)
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&full, "full", false, "Show sensitive fields (password hash, auth token)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON (default for inspect)")
_ = asJSON // inspect is always JSON; flag kept for consistency
return cmd
}
func newBoxDeleteCommand() *cobra.Command {
var uploadRoot string
var force bool
var asJSON bool
cmd := &cobra.Command{
Use: "rm",
Aliases: []string{"del", "delete"},
Short: "Delete a box",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
boxID := args[0]
if !force {
fmt.Printf("This will permanently delete box %s and all its files.\n", boxID)
fmt.Print("Confirm (y/N): ")
var confirm string
if _, err := fmt.Scanln(&confirm); err != nil {
confirm = "n"
}
if strings.ToLower(strings.TrimSpace(confirm)) != "y" {
if asJSON {
fmt.Println(`{"deleted": false, "reason": "aborted"}`)
} else {
fmt.Println("Aborted.")
}
return nil
}
}
if err := boxstore.DeleteBox(boxID); err != nil {
if asJSON {
fmt.Printf(`{"deleted": false, "error": "%s"}\n`, strings.ReplaceAll(err.Error(), `"`, `\"`))
} else {
return fmt.Errorf("failed to delete box %s: %w", boxID, err)
}
return nil
}
if asJSON {
fmt.Printf(`{"deleted": true, "box_id": "%s"}\n`, boxID)
} else {
fmt.Printf("Box %s deleted.\n", boxID)
}
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
func newBoxChangeCommand() *cobra.Command {
var uploadRoot string
var retention int64
var retentionList bool
var password string
var zip bool
var oneTime bool
var renew bool
var renewSeconds int64
var asJSON bool
cmd := &cobra.Command{
Use: "change",
Aliases: []string{"update", "modify"},
Short: "Change box properties",
Long: "Change box properties: retention, password, zip, one-time download, renew expiry.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
boxID := args[0]
if retentionList {
printRetentionOptions()
return nil
}
changes, err := gatherBoxChanges(cmd.Flags(), retention, password, zip, oneTime, renew, renewSeconds)
if err != nil {
return err
}
if len(changes) == 0 {
fmt.Println("No changes specified. Use --retention, --password, --zip, --one-time, --renew, or --retention-list.")
return nil
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
}
for _, apply := range changes {
if err := apply(&manifest); err != nil {
return err
}
}
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
return fmt.Errorf("failed to save manifest for box %s: %w", boxID, err)
}
if asJSON {
return formatChangeResultJSON(boxID, manifest)
}
fmt.Printf("Box %s updated.\n", boxID)
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().Int64Var(&retention, "retention", 0, "Set retention seconds (use --retention-list for valid values)")
cmd.Flags().BoolVar(&retentionList, "retention-list", false, "List available retention options")
cmd.Flags().StringVar(&password, "password", "", "Set a new password (empty string to remove)")
cmd.Flags().BoolVar(&zip, "zip", true, "Allow ZIP downloads (default true, --zip=false to disable)")
cmd.Flags().BoolVar(&oneTime, "one-time", false, "Enable one-time download mode")
cmd.Flags().BoolVar(&renew, "renew", false, "Renew box expiry (use --renew-seconds for duration)")
cmd.Flags().Int64Var(&renewSeconds, "renew-seconds", 0, "Seconds to extend expiry by (used with --renew)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}
type changeFunc func(*models.BoxManifest) error
func gatherBoxChanges(flags *pflag.FlagSet, retention int64, password string, zip bool, oneTime bool, renew bool, renewSeconds int64) ([]changeFunc, error) {
var changes []changeFunc
if flags.Changed("retention") {
if retention < 0 {
return nil, fmt.Errorf("retention cannot be negative")
}
changes = append(changes, func(m *models.BoxManifest) error {
if m.OneTimeDownload {
m.OneTimeDownload = false
}
m.RetentionSecs = retention
for _, opt := range boxstore.RetentionOptions() {
if opt.Seconds == retention {
m.RetentionKey = opt.Key
m.RetentionLabel = opt.Label
return nil
}
}
m.RetentionKey = "custom"
return nil
})
}
if flags.Changed("password") {
changes = append(changes, func(m *models.BoxManifest) error {
if password == "" {
m.PasswordHash = ""
m.PasswordHashAlg = ""
m.AuthToken = ""
return nil
}
token, err := helpers.RandomHexID(16)
if err != nil {
return fmt.Errorf("could not generate auth token")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("could not hash password: %w", err)
}
m.PasswordHash = string(hash)
m.PasswordHashAlg = "bcrypt"
m.AuthToken = token
return nil
})
}
if flags.Changed("zip") {
changes = append(changes, func(m *models.BoxManifest) error {
if m.OneTimeDownload {
return nil
}
m.DisableZip = !zip
return nil
})
}
if flags.Changed("one-time") {
changes = append(changes, func(m *models.BoxManifest) error {
if oneTime {
m.OneTimeDownload = true
m.DisableZip = false
if boxstore.OneTimeDownloadExpiry() > 0 {
m.RetentionSecs = boxstore.OneTimeDownloadExpiry()
}
} else {
m.OneTimeDownload = false
}
return nil
})
}
if flags.Changed("renew") {
changes = append(changes, func(m *models.BoxManifest) error {
secs := renewSeconds
if secs <= 0 {
secs = m.RetentionSecs
}
return renewBoxExpiry(m, secs)
})
}
return changes, nil
}
func renewBoxExpiry(m *models.BoxManifest, seconds int64) error {
if seconds <= 0 || m.OneTimeDownload {
return nil
}
if m.ExpiresAt.IsZero() {
m.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return nil
}
m.ExpiresAt = m.ExpiresAt.Add(time.Duration(seconds) * time.Second)
return nil
}
func newBoxGetCommand() *cobra.Command {
var uploadRoot string
var asJSON bool
cmd := &cobra.Command{
Use: "get",
Short: "Get box URL and info",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if uploadRoot != "" {
boxstore.SetUploadRoot(uploadRoot)
}
boxID := args[0]
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
}
if asJSON {
return formatBoxGetJSON(boxID, manifest)
}
fmt.Printf("Box ID:\t%s\n", boxID)
fmt.Printf("URL:\t/box/%s\n", boxID)
if !manifest.CreatedAt.IsZero() {
fmt.Printf("Created:\t%s\n", manifest.CreatedAt.Format(time.RFC3339))
}
if !manifest.ExpiresAt.IsZero() {
fmt.Printf("Expires:\t%s\n", manifest.ExpiresAt.Format(time.RFC3339))
}
if boxstore.IsPasswordProtected(manifest) {
fmt.Println("Password:\tprotected")
}
if manifest.OneTimeDownload {
fmt.Println("Mode:\tone-time download")
}
return nil
},
}
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
return cmd
}

255
cmd/cmd_env.go Normal file
View File

@@ -0,0 +1,255 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"warpbox/lib/config"
"github.com/spf13/cobra"
)
func newEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "env",
Short: "Explore environment variable options",
Long: "List and inspect WarpBox environment variables sourced from the codebase.",
}
cmd.AddCommand(newEnvListCommand())
cmd.AddCommand(newEnvDescribeCommand())
return cmd
}
func newEnvListCommand() *cobra.Command {
var format string
var includeHidden bool
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List all environment variables",
RunE: func(cmd *cobra.Command, args []string) error {
return formatEnvList(format, includeHidden)
},
}
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json, env")
cmd.Flags().BoolVar(&includeHidden, "hidden", false, "Include non-editable and hard-limit settings")
return cmd
}
func newEnvDescribeCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "describe",
Aliases: []string{"show", "info", "get"},
Short: "Describe an environment variable",
Long: "Show detailed info about a specific env var or setting key.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return describeEnvVar(args[0])
},
}
return cmd
}
type envRow struct {
EnvName string
Key string
Label string
Type config.SettingType
Default string
Editable bool
HardLimit bool
Minimum int64
}
type describeRow struct {
EnvName string
Key string
Label string
Type config.SettingType
Default string
Value string
Source string
Editable bool
HardLimit bool
Minimum int64
}
func formatEnvList(format string, includeHidden bool) error {
allRows := buildAllEnvRows(includeHidden)
switch format {
case "json":
type envOut struct {
EnvName string `json:"env_name"`
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Default string `json:"default"`
Editable bool `json:"editable"`
HardLimit bool `json:"hard_limit"`
Minimum int64 `json:"minimum,omitempty"`
}
out := make([]envOut, len(allRows))
for i, r := range allRows {
out[i] = envOut{
EnvName: r.EnvName, Key: r.Key, Label: r.Label,
Type: string(r.Type), Default: r.Default, Editable: r.Editable,
HardLimit: r.HardLimit, Minimum: r.Minimum,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
case "env":
for _, r := range allRows {
fmt.Printf("%s=\"%s\"\n", r.EnvName, r.Default)
}
return nil
case "table", "":
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ENV NAME\tKey\tLabel\tType\tDefault\tEditable")
for _, r := range allRows {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%v\n",
r.EnvName, r.Key, r.Label, r.Type, r.Default, r.Editable)
}
return w.Flush()
default:
return fmt.Errorf("unknown format: %s (use 'table', 'json', or 'env')", format)
}
}
func buildAllEnvRows(includeHidden bool) []envRow {
cfg, loadErr := config.Load()
var rows []envRow
for _, def := range config.Definitions {
if !includeHidden && (!def.Editable || def.HardLimit) {
continue
}
row := envRow{
EnvName: def.EnvName,
Key: def.Key,
Label: def.Label,
Type: def.Type,
Editable: def.Editable,
HardLimit: def.HardLimit,
Minimum: def.Minimum,
}
if loadErr == nil {
row.Default = getEnvDefault(cfg, def)
}
rows = append(rows, row)
}
extra := buildExtraEnvRows(includeHidden)
rows = append(rows, extra...)
return rows
}
func getEnvDefault(cfg *config.Config, def config.SettingDefinition) string {
for _, row := range cfg.SettingRows() {
if row.Definition.Key == def.Key && row.Source == config.SourceDefault {
return row.Value
}
}
return ""
}
func buildExtraEnvRows(includeHidden bool) []envRow {
extra := []envRow{
{EnvName: "WARPBOX_ADMIN_ENABLED", Key: "admin_enabled", Label: "Admin interface mode", Type: config.SettingTypeText, Editable: false, Default: "auto"},
{EnvName: "WARPBOX_ADMIN_USERNAME", Key: "admin_username", Label: "Admin username", Type: config.SettingTypeText, Editable: false, Default: "admin"},
{EnvName: "WARPBOX_ADMIN_PASSWORD", Key: "admin_password", Label: "Admin password", Type: config.SettingTypeText, Editable: false, Default: "(none)"},
{EnvName: "WARPBOX_ADMIN_EMAIL", Key: "admin_email", Label: "Admin email", Type: config.SettingTypeText, Editable: false, Default: "(none)"},
{EnvName: "WARPBOX_ADMIN_COOKIE_SECURE", Key: "admin_cookie_secure", Label: "Admin cookie secure flag", Type: config.SettingTypeBool, Editable: false, Default: "false"},
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
}
return extra
}
func describeEnvVar(query string) error {
cfg, loadErr := config.Load()
for _, def := range config.Definitions {
if matchEnv(query, def.EnvName, def.Key) {
row := describeRow{
EnvName: def.EnvName,
Key: def.Key,
Label: def.Label,
Type: def.Type,
Editable: def.Editable,
HardLimit: def.HardLimit,
Minimum: def.Minimum,
}
if loadErr == nil {
for _, r := range cfg.SettingRows() {
if r.Definition.Key == def.Key {
row.Value = r.Value
row.Source = string(r.Source)
break
}
}
}
printDescribeRow(row)
return nil
}
}
extras := buildExtraEnvRows(true)
for _, er := range extras {
if matchEnv(query, er.EnvName, er.Key) {
row := describeRow{
EnvName: er.EnvName,
Key: er.Key,
Label: er.Label,
Type: er.Type,
Editable: er.Editable,
HardLimit: er.HardLimit,
Minimum: er.Minimum,
Default: er.Default,
}
printDescribeRow(row)
return nil
}
}
return fmt.Errorf("no environment variable found matching: %s\n\nUse 'warpbox env ls' to list all available options.", query)
}
func matchEnv(query, envName, key string) bool {
return strings.EqualFold(query, envName) || strings.EqualFold(query, key)
}
func printDescribeRow(r describeRow) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "Environment Variable:\t%s\n", r.EnvName)
fmt.Fprintf(w, "Setting Key:\t%s\n", r.Key)
fmt.Fprintf(w, "Label:\t%s\n", r.Label)
fmt.Fprintf(w, "Type:\t%s\n", r.Type)
fmt.Fprintf(w, "Editable (runtime):\t%v\n", r.Editable)
fmt.Fprintf(w, "Hard Limit:\t%v\n", r.HardLimit)
if r.Minimum > 0 {
fmt.Fprintf(w, "Minimum:\t%d\n", r.Minimum)
}
if r.Default != "" {
fmt.Fprintf(w, "Default:\t%s\n", r.Default)
}
if r.Value != "" {
fmt.Fprintf(w, "Current Value:\t%s\n", r.Value)
}
if r.Source != "" {
fmt.Fprintf(w, "Source:\t%s\n", r.Source)
}
w.Flush()
}

181
cmd/cmd_format.go Normal file
View File

@@ -0,0 +1,181 @@
package main
import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
"time"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
// ── List output ──────────────────────────────────────────────
func formatBoxSummariesTable(summaries []models.BoxSummary) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tFiles\tSize\tCreated\tExpires\tPassword\tOne-Time\tExpired")
for _, s := range summaries {
expires := "-"
if !s.ExpiresAt.IsZero() {
expires = s.ExpiresAt.Format("2006-01-02 15:04:05")
}
created := s.CreatedAt.Format("2006-01-02 15:04:05")
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%v\t%v\t%v\n",
s.ID, s.FileCount, s.TotalSizeLabel, created, expires,
s.PasswordProtected, s.OneTimeDownload, s.Expired)
}
return w.Flush()
}
func formatBoxSummariesJSON(summaries []models.BoxSummary) error {
type summaryOut struct {
ID string `json:"id"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Expired bool `json:"expired"`
OneTimeDownload bool `json:"one_time_download"`
PasswordProtected bool `json:"password_protected"`
}
out := make([]summaryOut, len(summaries))
for i, s := range summaries {
out[i] = summaryOut{
ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize,
TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt, Expired: s.Expired,
OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected,
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// ── View output ──────────────────────────────────────────────
func formatBoxSummaryJSON(s *models.BoxSummary) error {
type summaryOut struct {
ID string `json:"id"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Expired bool `json:"expired"`
OneTimeDownload bool `json:"one_time_download"`
PasswordProtected bool `json:"password_protected"`
}
out := summaryOut{
ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize,
TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt, Expired: s.Expired,
OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func printBoxSummary(s *models.BoxSummary) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "ID:\t%s\n", s.ID)
fmt.Fprintf(w, "Files:\t%d\n", s.FileCount)
fmt.Fprintf(w, "Total Size:\t%s\n", s.TotalSizeLabel)
if !s.CreatedAt.IsZero() {
fmt.Fprintf(w, "Created:\t%s\n", s.CreatedAt.Format(time.RFC3339))
}
if !s.ExpiresAt.IsZero() {
fmt.Fprintf(w, "Expires:\t%s\n", s.ExpiresAt.Format(time.RFC3339))
}
fmt.Fprintf(w, "Expired:\t%v\n", s.Expired)
fmt.Fprintf(w, "Password Protected:\t%v\n", s.PasswordProtected)
fmt.Fprintf(w, "One-Time Download:\t%v\n", s.OneTimeDownload)
w.Flush()
}
// ── Get output ───────────────────────────────────────────────
func formatBoxGetJSON(boxID string, manifest models.BoxManifest) error {
type getOut struct {
BoxID string `json:"box_id"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Expired bool `json:"expired"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
RetentionKey string `json:"retention_key,omitempty"`
RetentionLabel string `json:"retention_label,omitempty"`
}
out := getOut{
BoxID: boxID, URL: "/box/" + boxID,
Expired: boxstore.IsExpired(manifest),
}
if !manifest.CreatedAt.IsZero() {
out.CreatedAt = manifest.CreatedAt
}
if !manifest.ExpiresAt.IsZero() {
out.ExpiresAt = manifest.ExpiresAt
}
out.PasswordProtected = boxstore.IsPasswordProtected(manifest)
out.OneTimeDownload = manifest.OneTimeDownload
out.RetentionKey = manifest.RetentionKey
out.RetentionLabel = manifest.RetentionLabel
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// ── Change output ────────────────────────────────────────────
func formatChangeResultJSON(boxID string, manifest models.BoxManifest) error {
type changeOut struct {
BoxID string `json:"box_id"`
Updated bool `json:"updated"`
CreatedAt time.Time `json:"created_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Expired bool `json:"expired"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
DisableZip bool `json:"disable_zip"`
RetentionKey string `json:"retention_key,omitempty"`
RetentionLabel string `json:"retention_label,omitempty"`
RetentionSeconds int64 `json:"retention_seconds,omitempty"`
FileCount int `json:"file_count"`
}
out := changeOut{
BoxID: boxID, Updated: true,
Expired: boxstore.IsExpired(manifest),
PasswordProtected: boxstore.IsPasswordProtected(manifest),
OneTimeDownload: manifest.OneTimeDownload,
DisableZip: manifest.DisableZip,
RetentionKey: manifest.RetentionKey,
RetentionLabel: manifest.RetentionLabel,
RetentionSeconds: manifest.RetentionSecs,
FileCount: len(manifest.Files),
}
if !manifest.CreatedAt.IsZero() {
out.CreatedAt = manifest.CreatedAt
}
if !manifest.ExpiresAt.IsZero() {
out.ExpiresAt = manifest.ExpiresAt
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// ── Retention options ────────────────────────────────────────
func printRetentionOptions() {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Key\tLabel\tSeconds")
for _, opt := range boxstore.RetentionOptions() {
fmt.Fprintf(w, "%s\t%s\t%d\n", opt.Key, opt.Label, opt.Seconds)
}
w.Flush()
}

21
cmd/cmd_run.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"warpbox/lib/server"
"github.com/spf13/cobra"
)
func newRunCommand() *cobra.Command {
var addr string
cmd := &cobra.Command{
Use: "run",
Short: "Run the HTTP server",
Long: "Run the WarpBox HTTP server.",
RunE: func(cmd *cobra.Command, args []string) error {
return server.Run(addr)
},
}
cmd.Flags().StringVar(&addr, "addr", ":8080", "HTTP server address")
return cmd
}

View File

@@ -5,8 +5,6 @@ import (
"os"
"github.com/spf13/cobra"
"warpbox/lib/server"
)
func main() {
@@ -23,17 +21,8 @@ func newRootCommand() *cobra.Command {
Long: "WarpBox provides commands for running and managing the WarpBox service.",
}
var addr string
runCmd := &cobra.Command{
Use: "run",
Short: "Run the HTTP server",
Long: "Run the WarpBox HTTP server. The root endpoint responds with ok.",
RunE: func(cmd *cobra.Command, args []string) error {
return server.Run(addr)
},
}
runCmd.Flags().StringVar(&addr, "addr", ":8080", "HTTP server address")
rootCmd.AddCommand(runCmd)
rootCmd.AddCommand(newRunCommand())
rootCmd.AddCommand(newBoxCommand())
rootCmd.AddCommand(newEnvCommand())
return rootCmd
}

View File

@@ -0,0 +1,13 @@
services:
warpbox:
image: warpbox:latest
container_name: warpbox
ports:
- "8080:8080"
volumes:
# For podman please use :Z
# - ./data:/app/data:Z
- ./data:/app/data
env_file:
- .env
restart: unless-stopped

View File

@@ -19,6 +19,20 @@ The app starts from `cmd/main.go`. The `warpbox run` command calls the server
package, loads templates from `templates/*.html`, registers routes, mounts
`/static`, starts the thumbnail worker, and serves HTTP.
The main request surfaces are:
- `GET /` for the upload box UI.
- `GET /box/:id` for shared box pages.
- `GET /box/:id/login` and `POST /box/:id/login` for password-protected boxes.
- `GET /box/:id/download` for ZIP downloads.
- `GET /box/:id/files/:filename` for individual file downloads.
- `GET /box/:id/thumbnails/:file_id` for image previews.
- `POST /box` for new upload box creation.
- `POST /box/:id/files/:file_id/upload` for manifest-based uploads.
- `POST /box/:id/files/:file_id/status` for upload status updates.
- `POST /box/:id/upload` and `POST /upload` for legacy upload compatibility.
- `/admin/*` for the admin UI and settings.
## Frontend
The frontend is server-rendered HTML with vanilla JavaScript.
@@ -26,14 +40,15 @@ The frontend is server-rendered HTML with vanilla JavaScript.
- Templates live in `templates/`.
- Browser behavior lives in `static/js/app.js` and `static/js/box.js`.
- Styling lives in `static/css/`.
- Visual assets, fonts, icons, cursors, and sprites live under `static/`.
- Visual assets, fonts, icons, cursors, popups, and sprites live under
`static/`.
There is no frontend build step. The browser receives HTML from Gin templates
and static assets directly from the Go server.
## Storage
WarpBox uses the local filesystem instead of a database.
WarpBox uses the local filesystem for box data and BadgerDB for app metadata.
Uploaded boxes are stored under:
@@ -43,26 +58,30 @@ data/uploads/
Each box directory contains uploaded files plus a `.warpbox.json` manifest.
The manifest tracks file names, statuses, retention, password metadata,
download options, and thumbnail state.
download options, and thumbnail state. BadgerDB stores users, tags, sessions,
and runtime settings overrides.
## Upload Flow
```mermaid
sequenceDiagram
participant UI as Browser UI
participant API as Gin server
participant Browser as Browser UI
participant Server as Gin server
participant Store as boxstore
participant Disk as Local disk
UI->>API: POST /box
API->>Store: create manifest
Store->>Disk: write box directory + manifest
API-->>UI: box id + upload URLs
UI->>API: POST /box/:id/files/:file_id/upload
API->>Store: save file and update status
Browser->>Server: POST /box
Server->>Store: create box directory + manifest
Store->>Disk: write .warpbox.json
Server-->>Browser: box id + upload URLs
Browser->>Server: POST /box/:id/files/:file_id/upload
Server->>Store: save file and update manifest
Store->>Disk: write file + manifest
UI->>API: GET /box/:id/status
API-->>UI: current file states
Browser->>Server: POST /box/:id/files/:file_id/status
Server->>Store: update file status
Store->>Disk: rewrite manifest
Browser->>Server: GET /box/:id/status
Server-->>Browser: current file states
```
## Download Flow
@@ -73,6 +92,20 @@ Users can download individual files when the box allows it. ZIP downloads are
created on demand from the files currently marked complete. One-time download
boxes force ZIP download and delete the box after a successful ZIP response.
```mermaid
flowchart LR
Shared[Shared box page]
File[Individual file download]
Zip[ZIP download]
OneTime[One-time ZIP only]
Delete[Delete box after success]
Shared --> File
Shared --> Zip
OneTime --> Zip
Zip --> Delete
```
## Thumbnail Worker
The thumbnail worker is a background goroutine. On each pass it scans upload
@@ -117,15 +150,18 @@ Primary environment variables:
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES`
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
- `WARPBOX_SESSION_TTL_SECONDS`
- `WARPBOX_BOX_POLL_INTERVAL_MS`
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
accepts `auto`, `true`, or `false`.
The HTTP listen address is configured through the CLI flag:
```bash
@@ -150,7 +186,8 @@ lib/models/models.go Shared data structures
## Tests
Existing tests currently focus on the storage layer. Run them with:
Existing tests cover config, storage, server security, and metastore behavior.
Run them with:
```bash
go test ./...

18
go.mod
View File

@@ -3,51 +3,43 @@ module warpbox
go 1.23.0
require (
github.com/dgraph-io/badger/v4 v4.8.0
github.com/gin-contrib/gzip v1.0.1
github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
golang.org/x/crypto v0.39.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

33
go.sum
View File

@@ -2,24 +2,15 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
@@ -28,11 +19,6 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -43,8 +29,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -52,14 +36,15 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -73,8 +58,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -99,14 +86,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=

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

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

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

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

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

@@ -0,0 +1,257 @@
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 ExpireBox(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if delta <= 0 {
return manifest, fmt.Errorf("Invalid bump duration")
}
if manifest.OneTimeDownload {
return manifest, fmt.Errorf("One-time boxes cannot be extended")
}
base := manifest.ExpiresAt
now := time.Now().UTC()
if base.IsZero() || base.Before(now) {
base = now
}
manifest.ExpiresAt = base.Add(delta)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func reconcileManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
changed := false
for index, file := range manifest.Files {
path, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
continue
}
if file.Status == models.FileStatusReady && file.Size == info.Size() {
continue
}
// The manifest is the UI source of truth, but disk wins when an upload
// was saved and the final status write/response was interrupted.
manifest.Files[index].Size = info.Size()
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
manifest.Files[index].Status = models.FileStatusReady
changed = true
}
if changed {
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return manifest, err
}
}
return manifest, nil
}
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
var manifest models.BoxManifest
data, err := os.ReadFile(ManifestPath(boxID))
if err != nil {
return manifest, err
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, err
}
return manifest, nil
}
// Manifest writes are serialized because the browser can upload several files
// concurrently into the same box. Without this lock, status updates can race.
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(ManifestPath(boxID), data, 0644)
}

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

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

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

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

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

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

View File

@@ -1,744 +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")
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 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 {
files := make([]models.BoxFile, 0, len(manifest.Files))
for _, file := range manifest.Files {
files = append(files, DecorateFile(boxID, file))
}
return 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 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
}
if manifest.OneTimeDownload {
return
}
for _, file := range manifest.Files {
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
return
}
}
seconds := manifest.RetentionSecs
if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
// 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
}

View File

@@ -47,7 +47,11 @@ func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
}
}
func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
func TestStartRetentionUsesConfiguredOneTimeDownloadExpiry(t *testing.T) {
restoreExpiry := OneTimeDownloadExpiry()
defer SetOneTimeDownloadExpiry(restoreExpiry)
SetOneTimeDownloadExpiry(30)
manifest := models.BoxManifest{
RetentionSecs: 10,
OneTimeDownload: true,
@@ -56,11 +60,38 @@ func TestStartRetentionSkipsOneTimeDownload(t *testing.T) {
{ID: "two", Status: models.FileStatusReady},
},
}
before := time.Now().UTC()
startRetentionIfTerminalUnlocked(&manifest)
if manifest.ExpiresAt.IsZero() {
t.Fatal("expected one-time download expiry to start from configured expiry")
}
if manifest.ExpiresAt.Before(before.Add(29 * time.Second)) {
t.Fatalf("expected configured one-time expiry, got %s", manifest.ExpiresAt)
}
if manifest.ExpiresAt.After(before.Add(31 * time.Second)) {
t.Fatalf("expected configured one-time expiry near 30s, got %s", manifest.ExpiresAt)
}
}
func TestStartRetentionSkipsOneTimeDownloadWhenExpiryZero(t *testing.T) {
restoreExpiry := OneTimeDownloadExpiry()
defer SetOneTimeDownloadExpiry(restoreExpiry)
SetOneTimeDownloadExpiry(0)
manifest := models.BoxManifest{
RetentionSecs: 10,
OneTimeDownload: true,
Files: []models.BoxFile{
{ID: "one", Status: models.FileStatusReady},
},
}
startRetentionIfTerminalUnlocked(&manifest)
if !manifest.ExpiresAt.IsZero() {
t.Fatalf("expected one-time download box to avoid retention expiry, got %s", manifest.ExpiresAt)
t.Fatalf("expected zero one-time expiry to keep expiry unset, got %s", manifest.ExpiresAt)
}
}
@@ -115,6 +146,32 @@ func TestListFilesSkipsSymlinks(t *testing.T) {
}
}
func TestThumbnailTasksSkipOneTimeDownloadBoxes(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if err := WriteManifest(boxID, models.BoxManifest{
OneTimeDownload: true,
Files: []models.BoxFile{{
ID: "0123456789abcdef",
Name: "image.png",
MimeType: "image/png",
Status: models.FileStatusReady,
}},
}); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
if tasks := collectBoxThumbnailTasks(boxID, 10); len(tasks) != 0 {
t.Fatalf("expected no thumbnail tasks for one-time box, got %#v", tasks)
}
}
func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
@@ -147,3 +204,57 @@ func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
t.Fatal("expected legacy password hash to verify")
}
}
func TestExpireBoxMarksManifestExpired(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
manifest := models.BoxManifest{
CreatedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: time.Now().UTC().Add(time.Hour),
}
if err := WriteManifest(boxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
expired, err := ExpireBox(boxID)
if err != nil {
t.Fatalf("ExpireBox returned error: %v", err)
}
if !expired.ExpiresAt.Before(time.Now().UTC()) {
t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt)
}
}
func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "fedcba9876543210fedcba9876543210"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
base := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
manifest := models.BoxManifest{
CreatedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: base,
}
if err := WriteManifest(boxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
bumped, err := BumpBoxExpiry(boxID, 24*time.Hour)
if err != nil {
t.Fatalf("BumpBoxExpiry returned error: %v", err)
}
expected := base.Add(24 * time.Hour)
if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) {
t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt)
}
}

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

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

View File

@@ -95,7 +95,7 @@ func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil || IsExpired(manifest) {
if err != nil || IsExpired(manifest) || manifest.OneTimeDownload {
return nil
}

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

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

View File

@@ -1,564 +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"
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
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: 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,
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},
{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},
{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(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 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"
}

View File

@@ -35,9 +35,10 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
t.Setenv("WARPBOX_API_ENABLED", "false")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "0.5")
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
cfg, err := Load()
if err != nil {
@@ -50,7 +51,7 @@ func TestEnvironmentOverrides(t *testing.T) {
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
t.Fatal("expected boolean environment overrides to be applied")
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.BoxPollIntervalMS != 2000 {
@@ -59,6 +60,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if cfg.AdminUsername != "root" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
}
if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
}
@@ -66,25 +70,25 @@ func TestEnvironmentOverrides(t *testing.T) {
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
}
}
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
cfg, err := Load()
@@ -92,7 +96,7 @@ func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
}
@@ -141,8 +145,14 @@ func TestSettingsOverrideValidation(t *testing.T) {
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
t.Fatal("expected negative expiry override to fail")
}
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
t.Fatal("expected hard limit override to fail")
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "0.5"); err != nil {
t.Fatalf("expected global max file size override to succeed, got %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
}
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
t.Fatal("expected data_dir override to remain locked")
}
}
@@ -160,16 +170,21 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",

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

@@ -0,0 +1,99 @@
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_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, 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) {
key = NormalizeLegacySettingKey(key)
for _, def := range Definitions {
if def.Key == key {
return def, true
}
}
return SettingDefinition{}, false
}
func NormalizeLegacySettingKey(key string) string {
switch key {
case "global_max_file_size_bytes":
return SettingGlobalMaxFileSizeBytes
case "global_max_box_size_bytes":
return SettingGlobalMaxBoxSizeBytes
case "default_user_max_file_size_bytes":
return SettingDefaultUserMaxFileBytes
case "default_user_max_box_size_bytes":
return SettingDefaultUserMaxBoxBytes
default:
return key
}
}
func NormalizeOverrideInput(key string, value string) (string, string, error) {
normalizedKey := NormalizeLegacySettingKey(key)
switch key {
case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes":
parsed, err := parseInt64(value, 0)
if err != nil {
return normalizedKey, "", err
}
return normalizedKey, formatGigabytesFromBytes(parsed), nil
default:
return normalizedKey, value, nil
}
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {
if def.Editable && !def.HardLimit {
defs = append(defs, def)
}
}
return defs
}

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

@@ -0,0 +1,276 @@
package config
import (
"fmt"
"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),
defaults: 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
gbName string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applySizeEnv(item.key, item.gbName, 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.captureDefaultValue(SettingDataDir, cfg.DataDir)
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
}
func (cfg *Config) captureDefaultValue(key string, value string) {
cfg.setValue(key, value, SourceDefault)
if cfg.defaults != nil {
cfg.defaults[key] = value
}
}
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) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
parsed, err := parseGigabytes(rawGB, float64(min))
if err != nil {
return fmt.Errorf("%s: %w", gbName, err)
}
*target = parsed
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
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, formatGigabytesFromBytes(parsed), 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)
}
parsedBytes := parsedMB * 1000 * 1000
*target = parsedBytes
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), 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
}

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

@@ -0,0 +1,102 @@
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_gb"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
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"
SettingTypeSizeGB SettingType = "size_gb"
)
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
defaults map[string]string
}

View File

@@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
const AdminSettingsOverrideFilename = "admin_settings_overrides.json"
type adminSettingsOverrideFile struct {
Format string `json:"format"`
SavedAt string `json:"saved_at"`
Overrides map[string]string `json:"overrides"`
}
func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
var payload adminSettingsOverrideFile
if err := json.Unmarshal(data, &payload); err != nil {
return nil, err
}
if payload.Overrides == nil {
return map[string]string{}, nil
}
normalized := make(map[string]string, len(payload.Overrides))
for key, value := range payload.Overrides {
nextKey, nextValue, err := NormalizeOverrideInput(key, value)
if err != nil {
return nil, err
}
normalized[nextKey] = nextValue
}
return normalized, nil
}
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
if overrides == nil {
overrides = map[string]string{}
}
keys := make([]string, 0, len(overrides))
for key := range overrides {
keys = append(keys, key)
}
sort.Strings(keys)
normalized := make(map[string]string, len(overrides))
for _, key := range keys {
normalized[key] = overrides[key]
}
payload := adminSettingsOverrideFile{
Format: "warpbox.admin.settings.overrides.v1",
SavedAt: time.Now().UTC().Format(time.RFC3339),
Overrides: normalized,
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

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

@@ -0,0 +1,136 @@
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 SettingTypeSizeGB:
parsed, err := parseGigabytes(value, float64(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 SettingGlobalMaxFileSizeBytes:
cfg.GlobalMaxFileSizeBytes = value
case SettingGlobalMaxBoxSizeBytes:
cfg.GlobalMaxBoxSizeBytes = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
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 (cfg *Config) DefaultValue(key string) string {
if cfg.defaults == nil {
return ""
}
return cfg.defaults[key]
}

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

@@ -0,0 +1,88 @@
package config
import (
"fmt"
"math"
"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
}
const bytesPerGigabyte = 1024 * 1024 * 1024
func parseGigabytes(value string, min float64) (int64, error) {
raw := strings.TrimSpace(value)
lower := strings.ToLower(raw)
if strings.HasSuffix(lower, "gb") {
raw = strings.TrimSpace(raw[:len(raw)-2])
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be a number of GB")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min))
}
bytes := parsed * bytesPerGigabyte
if bytes > math.MaxInt64 {
return 0, fmt.Errorf("is too large")
}
return int64(math.Round(bytes)), nil
}
func formatGigabytesFromBytes(bytes int64) string {
if bytes <= 0 {
return "0"
}
value := float64(bytes) / bytesPerGigabyte
return trimTrailingZeros(value)
}
func trimTrailingZeros(value float64) string {
text := strconv.FormatFloat(value, 'f', 3, 64)
text = strings.TrimRight(text, "0")
text = strings.TrimRight(text, ".")
if text == "" {
return "0"
}
return text
}
func formatBool(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -1,71 +0,0 @@
package metastore
import (
"strings"
"warpbox/lib/config"
)
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
adminTag, err := store.EnsureAdminTag()
if err != nil {
return BootstrapResult{}, err
}
var adminUser *User
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
if err != nil {
return BootstrapResult{}, err
}
if ok {
if !hasString(user.TagIDs, adminTag.ID) {
user.TagIDs = append(user.TagIDs, adminTag.ID)
if err := store.UpdateUser(user); err != nil {
return BootstrapResult{}, err
}
}
adminUser = &user
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
if err != nil {
return BootstrapResult{}, err
}
adminUser = &created
}
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
if err != nil {
return BootstrapResult{}, err
}
return BootstrapResult{
AdminTag: adminTag,
AdminUser: adminUser,
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
}, nil
}
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
users, err := store.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Disabled {
continue
}
if hasString(user.TagIDs, adminTagID) {
return true, nil
}
}
return false, nil
}
func hasString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -1,222 +0,0 @@
package metastore
import (
"errors"
"testing"
"time"
"warpbox/lib/config"
)
func TestOpenClose(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
func TestBootstrapAdminFromPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if !result.AdminLoginEnabled {
t.Fatal("expected admin login to be enabled")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to be protected")
}
if result.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
t.Fatal("expected bootstrap admin to have admin tag")
}
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
t.Fatal("expected bootstrap admin password to verify")
}
}
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if result.AdminLoginEnabled {
t.Fatal("expected admin login to be disabled without password or existing admin")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to still be created")
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers returned error: %v", err)
}
if len(users) != 0 {
t.Fatalf("expected no users, got %d", len(users))
}
}
func TestDuplicateUsersAndTags(t *testing.T) {
store := openTestStore(t)
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate username error, got %v", err)
}
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate email error, got %v", err)
}
tag := Tag{Name: "staff"}
if err := store.CreateTag(&tag); err != nil {
t.Fatalf("CreateTag returned error: %v", err)
}
duplicate := Tag{Name: "Staff"}
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate tag error, got %v", err)
}
}
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
tagFileLimit := int64(80)
tagBoxLimit := int64(2000)
userFileLimit := int64(60)
user := User{MaxFileSizeBytes: &userFileLimit}
tags := []Tag{
{
Permissions: TagPermissions{
UploadAllowed: true,
AllowedExpirySeconds: []int64{3600, 600},
MaxFileSizeBytes: &tagFileLimit,
MaxBoxSizeBytes: &tagBoxLimit,
ZipDownloadAllowed: true,
},
},
}
perms := ResolveUserPermissions(cfg, user, tags)
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
t.Fatal("expected tag booleans to grant permissions")
}
if perms.MaxFileSizeBytes != 80 {
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
}
if perms.MaxBoxSizeBytes != 1000 {
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
}
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
}
}
func TestSettingsStorageAndPrecedence(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_API_ENABLED", "true")
store := openTestStore(t)
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
t.Fatalf("SetSetting returned error: %v", err)
}
overrides, err := store.ListSettings()
if err != nil {
t.Fatalf("ListSettings returned error: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
if cfg.APIEnabled {
t.Fatal("expected stored DB override to beat env")
}
}
func TestSessionExpiry(t *testing.T) {
store := openTestStore(t)
session, err := store.CreateSession("user-id", time.Millisecond)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
time.Sleep(2 * time.Millisecond)
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func clearMetastoreConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

View File

@@ -1,76 +0,0 @@
package metastore
import "time"
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Protected bool `json:"protected"`
Permissions TagPermissions `json:"permissions"`
}
type TagPermissions struct {
UploadAllowed bool `json:"upload_allowed"`
AllowedExpirySeconds []int64 `json:"allowed_expiry_seconds,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
OneTimeDownloadAllowed bool `json:"one_time_download_allowed"`
ZipDownloadAllowed bool `json:"zip_download_allowed"`
RenewableAllowed bool `json:"renewable_allowed"`
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
}
type Session struct {
Token string `json:"token"`
CSRFToken string `json:"csrf_token"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type EffectivePermissions struct {
UploadAllowed bool
AllowedExpirySeconds []int64
MaxFileSizeBytes int64
MaxBoxSizeBytes int64
MaxExpirySeconds int64
OneTimeDownloadAllowed bool
ZipDownloadAllowed bool
RenewableAllowed bool
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
}
type BootstrapResult struct {
AdminTag Tag
AdminUser *User
AdminLoginEnabled bool
}

View File

@@ -1,141 +0,0 @@
package metastore
import (
"sort"
"warpbox/lib/config"
)
func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) EffectivePermissions {
perms := EffectivePermissions{
MaxFileSizeBytes: cfg.DefaultUserMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.DefaultUserMaxBoxSizeBytes,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
}
expirySet := make(map[int64]bool)
for _, tag := range tags {
tagPerms := tag.Permissions
perms.UploadAllowed = perms.UploadAllowed || tagPerms.UploadAllowed
perms.OneTimeDownloadAllowed = perms.OneTimeDownloadAllowed || tagPerms.OneTimeDownloadAllowed
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
perms.RenewOnAccessSeconds = maxInt64(perms.RenewOnAccessSeconds, tagPerms.RenewOnAccessSeconds)
perms.RenewOnDownloadSeconds = maxInt64(perms.RenewOnDownloadSeconds, tagPerms.RenewOnDownloadSeconds)
if tagPerms.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *tagPerms.MaxFileSizeBytes)
}
if tagPerms.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *tagPerms.MaxBoxSizeBytes)
}
for _, seconds := range tagPerms.AllowedExpirySeconds {
if seconds >= 0 {
expirySet[seconds] = true
}
}
}
if user.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *user.MaxFileSizeBytes)
}
if user.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *user.MaxBoxSizeBytes)
}
if user.MaxExpirySeconds != nil {
perms.MaxExpirySeconds = *user.MaxExpirySeconds
}
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
if !cfg.ZipDownloadsEnabled {
perms.ZipDownloadAllowed = false
}
if !cfg.OneTimeDownloadsEnabled {
perms.OneTimeDownloadAllowed = false
}
return perms
}
func ResolveGuestPermissions(cfg *config.Config) EffectivePermissions {
return EffectivePermissions{
UploadAllowed: cfg.GuestUploadsEnabled,
AllowedExpirySeconds: guestExpirySeconds(cfg),
MaxFileSizeBytes: cfg.GlobalMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.GlobalMaxBoxSizeBytes,
MaxExpirySeconds: cfg.MaxGuestExpirySeconds,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
RenewableAllowed: cfg.RenewOnAccessEnabled || cfg.RenewOnDownloadEnabled,
}
}
func morePermissiveLimit(current int64, candidate int64) int64 {
if current == 0 || candidate == 0 {
return 0
}
if candidate > current {
return candidate
}
return current
}
func capLimit(value int64, globalMax int64) int64 {
if globalMax == 0 {
return value
}
if value == 0 || value > globalMax {
return globalMax
}
return value
}
func sortedExpirySet(expirySet map[int64]bool) []int64 {
values := make([]int64, 0, len(expirySet))
for value := range expirySet {
values = append(values, value)
}
sort.Slice(values, func(i int, j int) bool {
return values[i] < values[j]
})
return values
}
func guestExpirySeconds(cfg *config.Config) []int64 {
values := []int64{}
if cfg.DefaultGuestExpirySeconds >= 0 {
values = append(values, cfg.DefaultGuestExpirySeconds)
}
if cfg.MaxGuestExpirySeconds > 0 && cfg.MaxGuestExpirySeconds != cfg.DefaultGuestExpirySeconds {
values = append(values, cfg.MaxGuestExpirySeconds)
}
return uniqueInt64s(values)
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i int, j int) bool {
return out[i] < out[j]
})
return out
}
func maxInt64(a int64, b int64) int64 {
if b > a {
return b
}
return a
}

View File

@@ -1,79 +0,0 @@
package metastore
import (
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return Session{}, fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
if ttl <= 0 {
return Session{}, fmt.Errorf("%w: session ttl must be positive", ErrInvalid)
}
token, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
csrfToken, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
Token: token,
CSRFToken: csrfToken,
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, sessionKey(token), session)
})
return session, err
}
func (store *Store) GetSession(token string) (Session, bool, error) {
token = strings.TrimSpace(token)
if token == "" {
return Session{}, false, nil
}
var session Session
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, sessionKey(token), &session)
})
if errors.Is(err, ErrNotFound) {
return Session{}, false, nil
}
if err != nil {
return Session{}, false, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = store.DeleteSession(token)
return Session{}, false, nil
}
return session, true, nil
}
func (store *Store) DeleteSession(token string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(sessionKey(token))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func sessionKey(token string) []byte {
return []byte("session/" + strings.TrimSpace(token))
}

View File

@@ -1,379 +0,0 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate")
ErrInvalid = errors.New("invalid")
)
type Store struct {
db *badger.DB
}
func Open(path string) (*Store, error) {
opts := badger.DefaultOptions(path).WithLogger(nil)
db, err := badger.Open(opts)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (store *Store) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func (store *Store) SetSetting(name string, value string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
}
return store.db.Update(func(txn *badger.Txn) error {
return txn.Set(settingKey(name), []byte(value))
})
}
func (store *Store) DeleteSetting(name string) error {
return store.db.Update(func(txn *badger.Txn) error {
return txn.Delete(settingKey(name))
})
}
func (store *Store) GetSetting(name string) (string, bool, error) {
var value string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(settingKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
value = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return "", false, nil
}
return value, err == nil, err
}
func (store *Store) ListSettings() (map[string]string, error) {
settings := make(map[string]string)
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("setting/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
name := strings.TrimPrefix(string(item.Key()), "setting/")
if err := item.Value(func(data []byte) error {
settings[name] = string(data)
return nil
}); err != nil {
return err
}
}
return nil
})
return settings, err
}
func HashPassword(password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(hash string, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
user := User{
Username: username,
Email: email,
PasswordHash: hash,
TagIDs: uniqueStrings(tagIDs),
}
if err := store.CreateUser(&user); err != nil {
return User{}, err
}
return user, nil
}
func (store *Store) CreateUser(user *User) error {
if user == nil {
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
}
username := strings.TrimSpace(user.Username)
if username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
email := strings.TrimSpace(user.Email)
if user.PasswordHash == "" {
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if user.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
user.ID = id
}
user.Username = username
user.Email = email
user.TagIDs = uniqueStrings(user.TagIDs)
user.CreatedAt = now
user.UpdatedAt = now
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if email != "" {
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
}
if err := putJSON(txn, userKey(user.ID), user); err != nil {
return err
}
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
return err
}
if email != "" {
return txn.Set(emailKey(email), []byte(user.ID))
}
return nil
})
}
func (store *Store) UpdateUser(user User) error {
if strings.TrimSpace(user.ID) == "" {
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.TrimSpace(user.Email)
if user.Username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
user.TagIDs = uniqueStrings(user.TagIDs)
user.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var existing User
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
return err
}
oldUsername := normalizeIndex(existing.Username)
newUsername := normalizeIndex(user.Username)
if oldUsername != newUsername {
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
return err
}
}
oldEmail := normalizeIndex(existing.Email)
newEmail := normalizeIndex(user.Email)
if oldEmail != newEmail {
if newEmail != "" {
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
return err
}
}
if oldEmail != "" {
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
}
}
return putJSON(txn, userKey(user.ID), user)
})
}
func (store *Store) GetUser(id string) (User, bool, error) {
var user User
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, userKey(id), &user)
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
return user, err == nil, err
}
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
return store.getUserByIndex(usernameKey(username))
}
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
return store.getUserByIndex(emailKey(email))
}
func (store *Store) ListUsers() ([]User, error) {
users := []User{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("user/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var user User
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &user)
}); err != nil {
return err
}
users = append(users, user)
}
return nil
})
return users, err
}
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
return store.GetUser(id)
}
func putJSON(txn *badger.Txn, key []byte, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return txn.Set(key, data)
}
func getJSON(txn *badger.Txn, key []byte, value any) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
return json.Unmarshal(data, value)
})
}
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
_, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return false, nil
}
return err == nil, err
}
func settingKey(name string) []byte {
return []byte("setting/" + strings.TrimSpace(name))
}
func userKey(id string) []byte {
return []byte("user/" + strings.TrimSpace(id))
}
func usernameKey(username string) []byte {
return []byte("user_by_name/" + normalizeIndex(username))
}
func emailKey(email string) []byte {
return []byte("user_by_email/" + normalizeIndex(email))
}
func normalizeIndex(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

View File

@@ -1,220 +0,0 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func AdminPermissions() TagPermissions {
unlimited := int64(0)
return TagPermissions{
UploadAllowed: true,
MaxFileSizeBytes: &unlimited,
MaxBoxSizeBytes: &unlimited,
OneTimeDownloadAllowed: true,
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,
}
}
func (store *Store) EnsureAdminTag() (Tag, error) {
tag, ok, err := store.GetTagByName(AdminTagName)
if err != nil {
return Tag{}, err
}
if ok {
tag.Protected = true
tag.Permissions = AdminPermissions()
tag.Description = "Built-in administrator permissions"
if err := store.UpdateTag(tag); err != nil {
return Tag{}, err
}
return tag, nil
}
tag = Tag{
Name: AdminTagName,
Description: "Built-in administrator permissions",
Protected: true,
Permissions: AdminPermissions(),
}
if err := store.CreateTag(&tag); err != nil {
return Tag{}, err
}
return tag, nil
}
func (store *Store) CreateTag(tag *Tag) error {
if tag == nil {
return fmt.Errorf("%w: tag cannot be nil", ErrInvalid)
}
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if tag.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
tag.ID = id
}
tag.CreatedAt = now
tag.UpdatedAt = now
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := putJSON(txn, tagKey(tag.ID), tag); err != nil {
return err
}
return txn.Set(tagNameKey(tag.Name), []byte(tag.ID))
})
}
func (store *Store) UpdateTag(tag Tag) error {
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.ID == "" {
return fmt.Errorf("%w: tag id cannot be empty", ErrInvalid)
}
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
tag.UpdatedAt = time.Now().UTC()
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
var existing Tag
if err := getJSON(txn, tagKey(tag.ID), &existing); err != nil {
return err
}
if normalizeIndex(existing.Name) != normalizeIndex(tag.Name) {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := txn.Delete(tagNameKey(existing.Name)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(tagNameKey(tag.Name), []byte(tag.ID)); err != nil {
return err
}
}
if existing.Protected {
tag.Protected = true
}
if tag.Name == AdminTagName {
tag.Protected = true
tag.Permissions = AdminPermissions()
}
return putJSON(txn, tagKey(tag.ID), tag)
})
}
func (store *Store) GetTag(id string) (Tag, bool, error) {
var tag Tag
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, tagKey(id), &tag)
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
return tag, err == nil, err
}
func (store *Store) GetTagByName(name string) (Tag, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(tagNameKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
if err != nil {
return Tag{}, false, err
}
return store.GetTag(id)
}
func (store *Store) ListTags() ([]Tag, error) {
tags := []Tag{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("tag/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var tag Tag
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &tag)
}); err != nil {
return err
}
tags = append(tags, tag)
}
return nil
})
return tags, err
}
func (store *Store) TagsByID(ids []string) ([]Tag, error) {
tags := make([]Tag, 0, len(ids))
for _, id := range ids {
tag, ok, err := store.GetTag(id)
if err != nil {
return nil, err
}
if ok {
tags = append(tags, tag)
}
}
return tags, nil
}
func normalizeTagPermissions(perms *TagPermissions) {
if perms == nil {
return
}
perms.AllowedExpirySeconds = uniqueInt64s(perms.AllowedExpirySeconds)
}
func tagKey(id string) []byte {
return []byte("tag/" + strings.TrimSpace(id))
}
func tagNameKey(name string) []byte {
return []byte("tag_by_name/" + normalizeIndex(name))
}

View File

@@ -53,6 +53,7 @@ type BoxManifest struct {
AuthToken string `json:"auth_token,omitempty"`
DisableZip bool `json:"disable_zip,omitempty"`
OneTimeDownload bool `json:"one_time_download,omitempty"`
Consumed bool `json:"consumed,omitempty"`
}
type BoxSummary struct {

View File

@@ -3,6 +3,7 @@ package routing
import "github.com/gin-gonic/gin"
type Handlers struct {
Health gin.HandlerFunc
Index gin.HandlerFunc
ShowBox gin.HandlerFunc
BoxLogin gin.HandlerFunc
@@ -16,9 +17,25 @@ type Handlers struct {
FileStatusUpdate gin.HandlerFunc
DirectBoxUpload gin.HandlerFunc
LegacyUpload gin.HandlerFunc
AdminLogin gin.HandlerFunc
AdminLoginPost gin.HandlerFunc
AdminLogout gin.HandlerFunc
AdminDashboard gin.HandlerFunc
AdminAlerts gin.HandlerFunc
AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc
AdminSettings gin.HandlerFunc
AdminSettingsExport gin.HandlerFunc
AdminSettingsSave gin.HandlerFunc
AdminSettingsImport gin.HandlerFunc
AdminSettingsReset gin.HandlerFunc
AdminAuth gin.HandlerFunc
}
func Register(router *gin.Engine, handlers Handlers) {
router.GET("/health", handlers.Health)
router.GET("/", handlers.Index)
router.GET("/box/:id", handlers.ShowBox)
@@ -36,4 +53,21 @@ func Register(router *gin.Engine, handlers Handlers) {
// Legacy upload routes are kept for compatibility with older clients.
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
router.POST("/upload", handlers.LegacyUpload)
admin := router.Group("/admin")
admin.GET("/login", handlers.AdminLogin)
admin.POST("/login", handlers.AdminLoginPost)
admin.GET("/logout", handlers.AdminLogout)
protected := router.Group("/admin", handlers.AdminAuth)
protected.GET("/dashboard", handlers.AdminDashboard)
protected.GET("/alerts", handlers.AdminAlerts)
protected.GET("/boxes", handlers.AdminBoxes)
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
protected.GET("/users", handlers.AdminUsers)
protected.GET("/settings", handlers.AdminSettings)
protected.GET("/settings/export", handlers.AdminSettingsExport)
protected.POST("/settings/save", handlers.AdminSettingsSave)
protected.POST("/settings/import", handlers.AdminSettingsImport)
protected.POST("/settings/reset", handlers.AdminSettingsReset)
}

View File

@@ -1,607 +1,116 @@
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"
const adminSessionMarker = "1"
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
func (app *App) adminLoginEnabled() bool {
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
}
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) adminAuthMiddleware(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
ctx.Abort()
return
}
token, err := ctx.Cookie(adminSessionCookie)
if err != nil || token != app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Next()
}
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) adminSessionToken() string {
// A simple deterministic token derived from the admin credentials.
// This will improve when proper user/session storage is added.
return app.config.AdminUsername + ":" + app.config.AdminPassword
}
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
app.renderAdminLogin(ctx, "")
// Already logged in.
if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
return
}
ctx.HTML(http.StatusOK, "admin/login.html", gin.H{})
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
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.")
if username != app.config.AdminUsername || password != app.config.AdminPassword {
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
"ErrorMessage": "Invalid username or password.",
})
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
}
secure := app.config.AdminCookieSecure
maxAge := int(app.config.SessionTTLSeconds)
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")
ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
}
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)
secure := app.config.AdminCookieSecure
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, 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),
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
dashboardEnabled := config.AdminEnabledTrue
if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue {
dashboardEnabled = cfgVal
}
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "dashboard",
"DashboardEnabled": string(dashboardEnabled),
})
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
func (app *App) handleAdminAlerts(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
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,
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "alerts",
})
}
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
}
}
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")
}

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

@@ -0,0 +1,316 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
)
type adminBoxesActionRequest struct {
Action string `json:"action"`
BoxIDs []string `json:"box_ids"`
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
}
type adminBoxFileView struct {
Name string `json:"name"`
SizeLabel string `json:"size_label"`
MimeType string `json:"mime_type"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
DownloadPath string `json:"download_path"`
ThumbnailURL string `json:"thumbnail_url"`
IsComplete bool `json:"is_complete"`
}
type adminBoxView struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
PendingFiles int `json:"pending_files"`
FailedFiles int `json:"failed_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
CreatedAtISO string `json:"created_at_iso"`
ExpiresAtLabel string `json:"expires_at_label"`
ExpiresAtISO string `json:"expires_at_iso"`
RetentionLabel string `json:"retention_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
ZipDisabled bool `json:"zip_disabled"`
ZipAvailable bool `json:"zip_available"`
Consumed bool `json:"consumed"`
HasManifest bool `json:"has_manifest"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
Files []adminBoxFileView `json:"files"`
SearchText string `json:"search_text"`
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load boxes")
return
}
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "boxes",
"Boxes": boxes,
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
})
}
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
var request adminBoxesActionRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
return
}
if len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
switch request.Action {
case "delete", "expire", "bump":
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
if request.Action == "bump" && request.DeltaSeconds <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
return
}
processed := 0
warnings := make([]string, 0)
for _, boxID := range request.BoxIDs {
if !boxstore.ValidBoxID(boxID) {
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
continue
}
var err error
switch request.Action {
case "delete":
err = boxstore.DeleteBox(boxID)
case "expire":
_, err = boxstore.ExpireBox(boxID)
case "bump":
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
}
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
continue
}
processed++
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
return
}
status := http.StatusOK
if processed == 0 && len(warnings) > 0 {
status = http.StatusBadRequest
}
ctx.JSON(status, gin.H{
"ok": len(warnings) == 0,
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
"warnings": warnings,
"boxes": boxes,
})
}
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
return nil, err
}
boxes := make([]adminBoxView, 0, len(summaries))
for _, summary := range summaries {
boxView, err := app.buildAdminBoxView(summary.ID)
if err != nil {
continue
}
boxes = append(boxes, boxView)
}
sort.Slice(boxes, func(i, j int) bool {
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
})
return boxes, nil
}
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
summary, err := boxstore.BoxSummary(boxID)
if err != nil {
return adminBoxView{}, err
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return adminBoxView{}, err
}
manifest, manifestErr := boxstore.ReadManifest(boxID)
hasManifest := manifestErr == nil
boxView := adminBoxView{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
ExpiresAtLabel: "Not set",
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
RetentionLabel: "Legacy / unmanaged",
PasswordProtected: summary.PasswordProtected,
OneTimeDownload: summary.OneTimeDownload,
HasManifest: hasManifest,
OpenURL: "/box/" + summary.ID,
Files: make([]adminBoxFileView, 0, len(files)),
}
if !summary.ExpiresAt.IsZero() {
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
}
searchParts := []string{summary.ID, summary.TotalSizeLabel}
for _, file := range files {
if file.IsComplete {
boxView.CompleteFiles++
}
if file.Status == "failed" {
boxView.FailedFiles++
}
if !file.IsComplete && file.Status != "failed" {
boxView.PendingFiles++
}
boxView.Files = append(boxView.Files, adminBoxFileView{
Name: file.Name,
SizeLabel: file.SizeLabel,
MimeType: file.MimeType,
Status: file.Status,
StatusLabel: file.StatusLabel,
DownloadPath: file.DownloadPath,
ThumbnailURL: file.ThumbnailURL,
IsComplete: file.IsComplete,
})
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
}
if hasManifest {
boxView.RetentionLabel = manifest.RetentionLabel
boxView.ZipDisabled = manifest.DisableZip
boxView.Consumed = manifest.Consumed
} else {
boxView.ZipDisabled = false
}
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
if boxView.ZipAvailable {
boxView.ZipURL = "/box/" + summary.ID + "/download"
}
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
boxView.Flags = deriveAdminBoxFlags(boxView)
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
return boxView, nil
}
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
switch {
case !hasManifest:
return "legacy", "Legacy"
case consumed:
return "consumed", "Consumed"
case expired:
return "expired", "Expired"
case pendingFiles > 0:
return "uploading", "Uploading"
case failedFiles > 0:
return "attention", "Needs review"
default:
return "ready", "Ready"
}
}
func deriveAdminBoxFlags(box adminBoxView) []string {
flags := make([]string, 0, 5)
if box.PasswordProtected {
flags = append(flags, "protected")
}
if box.OneTimeDownload {
flags = append(flags, "one-time")
}
if box.ZipDisabled {
flags = append(flags, "zip off")
}
if !box.HasManifest {
flags = append(flags, "legacy")
}
if box.Consumed {
flags = append(flags, "consumed")
}
return flags
}
func adminTimeLabel(value time.Time) string {
if value.IsZero() {
return "Not set"
}
return value.UTC().Format("2006-01-02 15:04 UTC")
}
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
switch action {
case "delete":
return fmt.Sprintf("Deleted %d box(es)", processed)
case "expire":
return fmt.Sprintf("Expired %d box(es)", processed)
case "bump":
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
default:
return "Action complete"
}
}
func adminBoxesDeltaLabel(deltaSeconds int64) string {
switch deltaSeconds {
case 24 * 60 * 60:
return "24h"
case 7 * 24 * 60 * 60:
return "7d"
default:
return (time.Duration(deltaSeconds) * time.Second).String()
}
}

View File

@@ -0,0 +1,471 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
type adminSettingsCategoryView struct {
Key string
Label string
Icon string
Count int
Rows []adminSettingRowView
}
type adminSettingRowView struct {
Key string `json:"key"`
Label string `json:"label"`
EnvName string `json:"env_name"`
Category string `json:"category"`
CategoryLabel string `json:"category_label"`
Type string `json:"type"`
Value string `json:"value"`
DefaultValue string `json:"default_value"`
Source string `json:"source"`
SourceBadge string `json:"source_badge"`
Editable bool `json:"editable"`
Locked bool `json:"locked"`
HardLimit bool `json:"hard_limit"`
Minimum int64 `json:"minimum"`
Description string `json:"description"`
}
type adminSettingsSaveRequest struct {
Values map[string]string `json:"values"`
}
type adminSettingsImportRequest struct {
Settings map[string]string `json:"settings"`
EditableSettings map[string]string `json:"editable_settings"`
Values map[string]string `json:"values"`
Changes map[string]string `json:"changes"`
}
type adminSettingsResetRequest struct {
Keys []string `json:"keys"`
}
type adminSettingsExportResponse struct {
Format string `json:"format"`
ExportedAt string `json:"exported_at"`
Settings map[string]string `json:"settings"`
EditableSettings map[string]string `json:"editable_settings"`
Rows []adminSettingRowView `json:"rows"`
}
func (app *App) handleAdminSettings(ctx *gin.Context) {
rows, categories := app.buildAdminSettingsRows()
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "settings",
"Rows": rows,
"Categories": categories,
"RowsJSON": rows,
})
}
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
rows, _ := app.buildAdminSettingsRows()
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
}
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
var request adminSettingsSaveRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
return
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
if currentOverrides == nil {
currentOverrides = map[string]string{}
}
for key, value := range request.Values {
currentOverrides[key] = value
}
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
var request adminSettingsImportRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
return
}
values := request.Values
if len(values) == 0 {
values = request.Settings
}
if len(values) == 0 {
values = request.EditableSettings
}
if len(values) == 0 {
values = request.Changes
}
if len(values) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
return
}
editable := map[string]bool{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = true
}
filtered := make(map[string]string, len(values))
warnings := make([]string, 0)
for key, value := range values {
if editable[key] {
filtered[key] = value
continue
}
if _, found := config.Definition(key); found {
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
continue
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
for key, value := range currentOverrides {
if _, exists := filtered[key]; !exists {
filtered[key] = value
}
}
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
warnings = append(warnings, applyWarnings...)
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
var request adminSettingsResetRequest
_ = ctx.ShouldBindJSON(&request)
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
return
}
if overrideSet == nil {
overrideSet = map[string]string{}
}
targetKeys := map[string]bool{}
for _, key := range request.Keys {
targetKeys[config.NormalizeLegacySettingKey(key)] = true
}
if len(targetKeys) == 0 {
overrideSet = map[string]string{}
} else {
for key := range targetKeys {
delete(overrideSet, key)
}
}
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": "Selected overrides cleared; environment and defaults now apply",
"warnings": warnings,
"rows": rows,
})
}
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
if !app.config.AllowAdminSettingsOverride {
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
}
if values == nil {
values = map[string]string{}
}
overrideSet := make(map[string]string, len(values))
warnings := make([]string, 0)
editable := map[string]config.SettingDefinition{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = def
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", key, err)
}
key = normalizedKey
value := normalizedValue
def, ok := editable[key]
if !ok {
if _, found := config.Definition(key); found {
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
continue
}
if value == "" && def.Type != config.SettingTypeText {
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
}
overrideSet[key] = value
}
nextCfg, err := config.Load()
if err != nil {
return nil, nil, err
}
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
return nil, nil, err
}
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
return nil, nil, err
}
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
settings := make(map[string]string, len(rows))
editable := make(map[string]string)
for _, row := range rows {
settings[row.Key] = row.Value
if row.Editable && !row.Locked {
editable[row.Key] = row.Value
}
}
return adminSettingsExportResponse{
Format: "warpbox.settings.export.v1",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Settings: settings,
EditableSettings: editable,
Rows: rows,
}
}
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
cfgRows := app.config.SettingRows()
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
for _, row := range cfgRows {
rows = append(rows, app.makeDefinitionSettingRow(row))
}
rows = append(rows,
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
)
sort.Slice(rows, func(i, j int) bool {
if rows[i].Category == rows[j].Category {
return rows[i].Label < rows[j].Label
}
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
})
categoryMeta := settingsCategoryMeta()
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
categories = append(categories, allCategory)
grouped := map[string][]adminSettingRowView{}
for _, row := range rows {
grouped[row.Category] = append(grouped[row.Category], row)
}
for _, meta := range categoryMeta {
categories = append(categories, adminSettingsCategoryView{
Key: meta.Key,
Label: meta.Label,
Icon: meta.Icon,
Count: len(grouped[meta.Key]),
Rows: grouped[meta.Key],
})
}
return rows, categories
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
def := row.Definition
locked := !def.Editable || def.HardLimit
source := string(row.Source)
sourceBadge := source
if locked {
sourceBadge = "hard env"
}
return adminSettingRowView{
Key: def.Key,
Label: def.Label,
EnvName: def.EnvName,
Category: settingsCategoryForKey(def.Key),
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
Type: string(def.Type),
Value: row.Value,
DefaultValue: app.config.DefaultValue(def.Key),
Source: source,
SourceBadge: sourceBadge,
Editable: def.Editable && !def.HardLimit,
Locked: locked,
HardLimit: def.HardLimit,
Minimum: def.Minimum,
Description: settingsDescription(def.Key),
}
}
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
return adminSettingRowView{
Key: key,
Label: label,
EnvName: envName,
Category: category,
CategoryLabel: settingsCategoryLabel(category),
Type: rowType,
Value: value,
DefaultValue: "",
Source: "environment",
SourceBadge: "hard env",
Editable: false,
Locked: true,
HardLimit: true,
Description: description,
}
}
type settingsCategoryInfo struct {
Key string
Label string
Icon string
}
func settingsCategoryMeta() []settingsCategoryInfo {
return []settingsCategoryInfo{
{Key: "uploads", Label: "Uploads", Icon: "↥"},
{Key: "downloads", Label: "Downloads", Icon: "↧"},
{Key: "retention", Label: "Retention", Icon: "⌛"},
{Key: "accounts", Label: "Accounts", Icon: "☺"},
{Key: "api", Label: "API", Icon: "{ }"},
{Key: "storage", Label: "Storage", Icon: "▥"},
{Key: "workers", Label: "Workers", Icon: "⚙"},
}
}
func settingsCategoryLabel(key string) string {
for _, meta := range settingsCategoryMeta() {
if meta.Key == key {
return meta.Label
}
}
return "General"
}
func settingsCategoryRank(key string) int {
for index, meta := range settingsCategoryMeta() {
if meta.Key == key {
return index
}
}
return len(settingsCategoryMeta()) + 1
}
func settingsCategoryForKey(key string) string {
switch key {
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
return "uploads"
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
return "downloads"
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
return "retention"
case config.SettingSessionTTLSeconds:
return "accounts"
case config.SettingAPIEnabled:
return "api"
case config.SettingDataDir:
return "storage"
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
return "workers"
default:
return "accounts"
}
}
func settingsDescription(key string) string {
descriptions := map[string]string{
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.",
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.",
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
}
return descriptions[key]
}

View File

@@ -0,0 +1,271 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
func TestAdminSettingsRequiresAuth(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("expected login redirect, got %q", location)
}
if app == nil {
t.Fatal("expected app setup")
}
}
func TestAdminSettingsPageRenders(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "WarpBox Settings") {
t.Fatalf("expected settings page title, got %s", body)
}
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
t.Fatalf("expected API env var in page body")
}
}
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
var payload struct {
Format string `json:"format"`
Settings map[string]string `json:"settings"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload.Format != "warpbox.settings.export.v1" {
t.Fatalf("unexpected export format: %q", payload.Format)
}
if payload.Settings[config.SettingAPIEnabled] != "false" {
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
}
}
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000","global_max_file_size_gb":"0.5"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if !app.config.APIEnabled {
t.Fatal("expected APIEnabled override to be applied")
}
if app.config.BoxPollIntervalMS != 6000 {
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
}
if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes)
}
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
}
if overrides[config.SettingAPIEnabled] != "true" {
t.Fatalf("expected persisted API override, got %#v", overrides)
}
if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists {
t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides)
}
if _, exists := overrides[config.SettingSessionTTLSeconds]; exists {
t.Fatalf("expected untouched setting to stay out of overrides, got %#v", overrides)
}
}
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
}
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if !app.config.APIEnabled {
t.Fatal("expected editable import value to apply")
}
var payload struct {
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if len(payload.Warnings) != 2 {
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
}
}
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected reset to respect environment and restore APIEnabled=false")
}
}
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd returned error: %v", err)
}
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("Chdir returned error: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(cwd)
})
clearAdminSettingsEnv(t)
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
t.Setenv("WARPBOX_API_ENABLED", "false")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.EnsureDirectories(); err != nil {
t.Fatalf("EnsureDirectories returned error: %v", err)
}
app := &App{
config: cfg,
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
}
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
t.Fatalf("loadHTMLTemplates returned error: %v", err)
}
router := gin.New()
router.SetHTMLTemplate(htmlTemplates)
admin := router.Group("/admin")
admin.GET("/login", app.handleAdminLogin)
protected := router.Group("/admin", app.adminAuthMiddleware)
protected.GET("/settings", app.handleAdminSettings)
protected.GET("/settings/export", app.handleAdminSettingsExport)
protected.POST("/settings/save", app.handleAdminSettingsSave)
protected.POST("/settings/import", app.handleAdminSettingsImport)
protected.POST("/settings/reset", app.handleAdminSettingsReset)
return app, router
}
func authCookie(app *App) *http.Cookie {
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
}
func clearAdminSettingsEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

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

@@ -0,0 +1,20 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "users",
})
}

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

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

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

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

View File

@@ -1,804 +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
}
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, _, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok {
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
return
}
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 {
ctx.String(http.StatusNotFound, "Box not found")
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.writeBoxZip(ctx, boxID, files) {
return
}
boxstore.DeleteBox(boxID)
}
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
ctx.Header("Content-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
zipWriter := zip.NewWriter(ctx.Writer)
zipClosed := false
defer func() {
if !zipClosed {
zipWriter.Close()
}
}()
for _, file := range files {
if !file.IsComplete {
continue
}
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
ctx.Status(http.StatusInternalServerError)
return false
}
}
if err := zipWriter.Close(); err != nil {
zipClosed = true
ctx.Status(http.StatusInternalServerError)
return false
}
zipClosed = true
return true
}
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 (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
}
if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized {
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 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,
})
}

219
lib/server/one_time_test.go Normal file
View File

@@ -0,0 +1,219 @@
package server
import (
"archive/zip"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/models"
)
const oneTimeTestBoxID = "0123456789abcdef0123456789abcdef"
func TestOneTimeDownloadNotReadyDoesNotConsume(t *testing.T) {
app := setupOneTimeDownloadTest(t, false)
writeOneTimeManifest(t, models.FileStatusWork, false)
response := performOneTimeDownload(app)
if response.Code != http.StatusConflict {
t.Fatalf("expected not-ready download to return 409, got %d", response.Code)
}
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if manifest.Consumed {
t.Fatal("expected not-ready box to remain unconsumed")
}
}
func TestOneTimeDownloadReadyConsumesAndDeletes(t *testing.T) {
app := setupOneTimeDownloadTest(t, false)
writeOneTimeManifest(t, models.FileStatusReady, true)
response := performOneTimeDownload(app)
if response.Code != http.StatusOK {
t.Fatalf("expected ready download to return 200, got %d", response.Code)
}
if _, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len())); err != nil {
t.Fatalf("expected valid zip body: %v", err)
}
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
t.Fatalf("expected consumed box to be deleted, stat err=%v", err)
}
}
func TestOneTimeDownloadWriterFailureConsumesByDefault(t *testing.T) {
app := setupOneTimeDownloadTest(t, false)
writeOneTimeManifest(t, models.FileStatusReady, false)
response := performOneTimeDownload(app)
if response.Code != http.StatusInternalServerError {
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
}
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
t.Fatalf("expected failed ZIP to delete box by default, stat err=%v", err)
}
}
func TestOneTimeDownloadWriterFailureCanRemainRetryable(t *testing.T) {
app := setupOneTimeDownloadTest(t, true)
writeOneTimeManifest(t, models.FileStatusReady, false)
response := performOneTimeDownload(app)
if response.Code != http.StatusInternalServerError {
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
}
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if manifest.Consumed {
t.Fatal("expected failed retryable ZIP to remain unconsumed")
}
}
func TestOneTimeDownloadSecondAccessAfterConsumeIsGone(t *testing.T) {
app := setupOneTimeDownloadTest(t, false)
writeOneTimeManifest(t, models.FileStatusReady, true)
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
manifest.Consumed = true
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
response := performOneTimeDownload(app)
if response.Code != http.StatusGone {
t.Fatalf("expected consumed download to return 410, got %d", response.Code)
}
}
func TestOneTimeStatusStripsThumbnailPath(t *testing.T) {
app := setupOneTimeDownloadTest(t, false)
app.config.APIEnabled = true
writeOneTimeManifest(t, models.FileStatusReady, true)
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
thumbnailPath := "/box/" + oneTimeTestBoxID + "/thumbnails/0123456789abcdef"
manifest.Files[0].ThumbnailPath = &thumbnailPath
manifest.Files[0].ThumbnailStatus = models.ThumbnailStatusReady
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
response := performOneTimeStatus(app)
if response.Code != http.StatusOK {
t.Fatalf("expected status to return 200, got %d", response.Code)
}
var payload struct {
Files []models.BoxFile `json:"files"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if len(payload.Files) != 1 {
t.Fatalf("expected one file, got %#v", payload.Files)
}
if payload.Files[0].ThumbnailPath != nil {
t.Fatalf("expected one-time status to strip thumbnail path, got %q", *payload.Files[0].ThumbnailPath)
}
}
func TestRuntimeConfigAppliesDBOneTimeExpiryOverride(t *testing.T) {
restoreExpiry := boxstore.OneTimeDownloadExpiry()
defer boxstore.SetOneTimeDownloadExpiry(restoreExpiry)
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(map[string]string{config.SettingOneTimeDownloadExpirySecs: "42"}); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
applyBoxstoreRuntimeConfig(cfg)
if got := boxstore.OneTimeDownloadExpiry(); got != 42 {
t.Fatalf("expected runtime one-time expiry to be updated from config, got %d", got)
}
}
func setupOneTimeDownloadTest(t *testing.T, retryOnFailure bool) *App {
t.Helper()
gin.SetMode(gin.TestMode)
restoreUploadRoot := boxstore.UploadRoot()
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
boxstore.SetUploadRoot(t.TempDir())
return &App{config: &config.Config{
ZipDownloadsEnabled: true,
OneTimeDownloadRetryOnFailure: retryOnFailure,
}}
}
func writeOneTimeManifest(t *testing.T, status string, createFile bool) {
t.Helper()
if err := os.MkdirAll(boxstore.BoxPath(oneTimeTestBoxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
if createFile {
path, ok := boxstore.SafeBoxFilePath(oneTimeTestBoxID, "file.txt")
if !ok {
t.Fatal("SafeBoxFilePath rejected test file")
}
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
}
manifest := models.BoxManifest{
Files: []models.BoxFile{{
ID: "0123456789abcdef",
Name: "file.txt",
Size: 5,
MimeType: "text/plain",
Status: status,
}},
CreatedAt: time.Now().UTC(),
OneTimeDownload: true,
}
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
}
func performOneTimeDownload(app *App) *httptest.ResponseRecorder {
router := gin.New()
router.GET("/box/:id/download", app.handleDownloadBox)
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/download", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func performOneTimeStatus(app *App) *httptest.ResponseRecorder {
router := gin.New()
router.GET("/box/:id/status", app.handleBoxStatus)
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/status", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

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

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

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

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

View File

@@ -1,17 +1,12 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
@@ -40,40 +35,3 @@ func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
}
}
func TestAdminProtectedPostRequiresCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
adminTag, err := store.EnsureAdminTag()
if err != nil {
t.Fatalf("EnsureAdminTag returned error: %v", err)
}
user, err := store.CreateUserWithPassword("admin", "", "secret", []string{adminTag.ID})
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
session, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
app := &App{config: &config.Config{}, store: store}
router := gin.New()
router.POST("/admin/test", app.requireAdminSession, func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "/admin/test", nil)
request.AddCookie(&http.Cookie{Name: adminSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
}
}

View File

@@ -1,7 +1,9 @@
package server
import (
"fmt"
"encoding/json"
"html/template"
"path/filepath"
"time"
"github.com/gin-contrib/gzip"
@@ -9,14 +11,12 @@ import (
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing"
)
type App struct {
config *config.Config
store *metastore.Store
adminLoginEnabled bool
settingsOverridesPath string
}
func Run(addr string) error {
@@ -27,38 +27,28 @@ func Run(addr string) error {
if err := cfg.EnsureDirectories(); err != nil {
return err
}
boxstore.SetUploadRoot(cfg.UploadsDir)
store, err := metastore.Open(cfg.DBDir)
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
if err != nil {
return fmt.Errorf("open metadata database: %w", err)
}
defer store.Close()
overrides, err := store.ListSettings()
if err != nil {
return fmt.Errorf("load settings overrides: %w", err)
return err
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err)
return err
}
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
return fmt.Errorf("bootstrap admin metadata: %w", err)
}
applyBoxstoreRuntimeConfig(cfg)
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
app := &App{config: cfg, settingsOverridesPath: overridesPath}
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
return err
}
router.SetHTMLTemplate(htmlTemplates)
routing.Register(router, routing.Handlers{
Health: app.handleHealth,
Index: app.handleIndex,
ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin,
@@ -72,8 +62,22 @@ func Run(addr string) error {
FileStatusUpdate: app.handleFileStatusUpdate,
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
AdminLogin: app.handleAdminLogin,
AdminLoginPost: app.handleAdminLoginPost,
AdminLogout: app.handleAdminLogout,
AdminDashboard: app.handleAdminDashboard,
AdminAlerts: app.handleAdminAlerts,
AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers,
AdminSettings: app.handleAdminSettings,
AdminSettingsExport: app.handleAdminSettingsExport,
AdminSettingsSave: app.handleAdminSettingsSave,
AdminSettingsImport: app.handleAdminSettingsImport,
AdminSettingsReset: app.handleAdminSettingsReset,
AdminAuth: app.adminAuthMiddleware,
})
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")
@@ -82,3 +86,38 @@ func Run(addr string) error {
return router.Run(addr)
}
func loadHTMLTemplates() (*template.Template, error) {
tmpl := template.New("").Funcs(template.FuncMap{
"toJSON": func(value any) template.JS {
data, err := json.Marshal(value)
if err != nil {
return template.JS("null")
}
return template.JS(data)
},
})
for _, pattern := range []string{
"templates/*.html",
"templates/admin/*.html",
"templates/admin/partials/*.html",
} {
var err error
tmpl, err = tmpl.ParseGlob(pattern)
if err != nil {
return nil, err
}
}
return tmpl, nil
}
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
}
func (app *App) handleHealth(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
})
}

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

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

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

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

21
run.sh
View File

@@ -1,16 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Load .env if exists
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
# Core service switches.
export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}"
export WARPBOX_ONE_TIME_DOWNLOADS_ENABLED="${WARPBOX_ONE_TIME_DOWNLOADS_ENABLED:-true}"
export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS:-604800}" # 7 days
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
# Storage and expiry limits used by the upload UI and backend validators.
# Use megabytes here; WarpBox converts these to bytes internally.
export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB
# Use decimal gigabytes here. Examples: 2, 4, 0.5
export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours
@@ -26,4 +33,10 @@ export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
export WARPBOX_ADMIN_ENABLED="${WARPBOX_ADMIN_ENABLED:-true}"
export WARPBOX_ADMIN_PASSWORD="${WARPBOX_ADMIN_PASSWORD:-123}"
go run ./cmd/main.go run
# Option to run via Docker Compose
if [ "${1:-}" = "--docker" ]; then
docker-compose up --build
exit 0
fi
go run ./cmd run

View File

@@ -1,132 +1,738 @@
body {
/* ===========================
Admin Shell / Frame
=========================== */
.admin-shell {
width: 100%;
min-height: 100vh;
}
.admin-window {
width: min(1120px, calc(100vw - 32px));
margin: 32px auto;
}
.admin-panel {
display: grid;
gap: 16px;
padding: 16px;
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);
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
align-items: center;
flex-direction: column;
padding: 10px 16px 34px;
gap: 10px;
}
.admin-spacer {
flex: 1;
}
.admin-grid {
.admin-frame {
width: min(var(--admin-frame-width, 1320px), 100%);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-rows: auto auto;
gap: 10px;
align-items: start;
}
.admin-link {
min-height: 88px;
padding: 12px;
color: inherit;
/* ===========================
Admin Taskbar (top nav)
=========================== */
.admin-taskbar {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
color: #000000;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.36), rgba(0,0,0,.08)), repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 4px 4px 0 rgba(0,0,0,.45);
padding: 3px;
position: sticky;
top: 0;
z-index: 50;
transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end);
}
.admin-taskbar.is-scrolled {
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0,0,0,.55), 0 11px 0 rgba(0,0,0,.18);
filter: brightness(1.02);
}
.admin-taskbar.is-scrolled::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -10px;
height: 10px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0,0,0,.46), rgba(0,0,0,0));
}
/* ===========================
Start Button
=========================== */
.admin-start-button {
min-width: 108px;
height: 24px;
display: inline-grid;
grid-template-columns: 18px 1fr;
align-items: center;
gap: 5px;
padding: 0 8px;
color: #000000;
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: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-weight: bold;
text-decoration: none;
background: #dfdfdf;
white-space: nowrap;
}
.admin-start-button:active {
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
padding-top: 1px;
}
.admin-start-logo {
width: 16px;
height: 16px;
display: grid;
place-items: center;
color: #ffffff;
background: #000078;
border: 1px solid #ffffff;
box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0;
font-size: 10px;
line-height: 10px;
}
/* ===========================
Taskbar Nav Buttons
=========================== */
.admin-taskbar-nav {
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1px;
}
.admin-taskbar-button {
height: 24px;
min-width: 76px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 8px;
color: #000000;
background: var(--w98-gray);
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;
text-decoration: none;
white-space: nowrap;
}
.admin-link strong,
.admin-link span {
.admin-taskbar-button:active {
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
padding-top: 1px;
}
.admin-taskbar-button.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.admin-taskbar-button:hover {
color: #ffffff;
background: #000078;
}
/* ===========================
Taskbar Session Chips
=========================== */
.admin-taskbar-session {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
white-space: nowrap;
}
.admin-session-chip,
.admin-alert-chip {
height: 24px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 8px;
background: #dfdfdf;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
color: #000000;
text-decoration: none;
white-space: nowrap;
}
.admin-alert-chip.is-ok { background: #e8ffe8; border-color: #008000 #ffffff #ffffff #008000; }
.admin-alert-chip.is-info { background: #d8e5f8; }
.admin-alert-chip.is-warning {
background: #ffffcc;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
}
.admin-alert-chip.is-danger {
color: #ffffff;
background: #800000;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3;
}
/* ===========================
Dashboard Window
=========================== */
.admin-dashboard-window,
.admin-workspace-window {
width: 100%;
min-height: 0;
padding: 0;
overflow: visible;
color: #000000;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
}
.admin-dashboard-window > .win98-titlebar,
.admin-workspace-window > .win98-titlebar {
margin: 2px 2px 0;
}
.admin-dashboard-window > .menu-bar,
.admin-workspace-window > .menu-bar {
flex: 0 0 auto;
height: auto;
min-height: 24px;
margin: 0 2px;
padding: 1px 6px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
z-index: 30;
}
.admin-dashboard-window > .menu-bar .menu-button,
.admin-workspace-window > .menu-bar .menu-button {
color: #000000;
}
.admin-dashboard-window > .dashboard-body,
.admin-workspace-window > .admin-workspace-body {
flex: 1 1 auto;
margin-top: 0;
padding: 0 10px 10px;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.05));
}
.admin-dashboard-statusbar {
grid-template-columns: minmax(0, 1fr) 160px 210px;
height: 28px;
padding: 3px 4px 4px;
background: var(--w98-gray);
font-size: 12px;
line-height: 14px;
}
.admin-dashboard-statusbar span {
min-height: 19px;
align-items: center;
padding: 1px 6px;
}
/* ===========================
Menu Bar (toolbar)
=========================== */
.admin-menu-bar {
position: relative;
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
padding: 1px 6px;
font-size: 13px;
line-height: 13px;
z-index: 20;
}
.admin-menu-item {
position: relative;
}
.admin-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;
}
.admin-menu-button:hover,
.admin-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;
}
.admin-menu-popup {
position: absolute;
top: 22px;
left: 0;
min-width: 220px;
padding: 2px;
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);
display: none;
z-index: 60;
}
.admin-menu-item.is-open .admin-menu-popup {
display: block;
}
.admin-link span {
margin-top: 8px;
}
.admin-table {
.admin-menu-action {
width: 100%;
border-collapse: collapse;
background: #fff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.admin-table th,
.admin-table td {
padding: 8px;
border: 1px solid #808080;
text-align: left;
vertical-align: top;
}
.admin-form {
min-height: 22px;
display: grid;
gap: 10px;
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;
}
.admin-form-row {
.admin-menu-action:hover,
.admin-menu-action:focus-visible {
color: #ffffff;
background: #000078;
outline: none;
}
.admin-menu-separator {
height: 1px;
margin: 3px 2px;
background: #808080;
border-bottom: 1px solid #ffffff;
}
.admin-menu-action .shortcut {
color: #555555;
}
.admin-menu-action:hover .shortcut {
color: #ffffff;
}
/* ===========================
Hero Section
=========================== */
.admin-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 10px;
padding: 9px;
align-items: stretch;
}
.admin-hero-copy h2 {
margin: 0 0 5px;
font-size: 22px;
line-height: 24px;
}
.admin-hero-copy p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 15px;
}
.admin-hero-status {
display: grid;
gap: 4px;
}
.admin-form-row input,
.admin-form-row textarea,
.admin-form-row select {
width: 100%;
min-height: 24px;
color: #000000;
align-content: center;
padding: 7px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 13px;
}
.admin-checks {
.admin-hero-status-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.admin-status-ok { color: #008000; }
.admin-status-warn { color: #8a6200; }
.admin-status-danger { color: #800000; }
/* ===========================
Stats Grid
=========================== */
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.admin-checks label {
display: flex;
gap: 6px;
align-items: center;
}
.admin-error {
padding: 8px;
border: 1px solid #800;
background: #ffdede;
}
.admin-summary {
.admin-stat-card {
position: relative;
min-height: 122px;
padding: 10px 11px 10px 14px;
overflow: hidden;
}
/* Left accent bar */
.admin-stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 7px;
border-left: 7px solid #000078;
pointer-events: none;
}
/* Severity color states */
.admin-stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
.admin-stat-card.is-ok::before { border-left-color: #008000; }
.admin-stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
.admin-stat-card.is-info::before { border-left-color: #000078; }
.admin-stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
.admin-stat-card.is-warning::before { border-left-color: #ffcc00; }
.admin-stat-card.is-danger {
color: #000000;
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
}
.admin-stat-card.is-danger::before { border-left-color: #800000; }
.admin-stat-label {
margin: 0 0 6px;
color: #333333;
font-size: 13px;
line-height: 13px;
font-weight: bold;
}
.admin-stat-value {
margin: 0 0 7px;
font-size: 32px;
line-height: 32px;
font-weight: bold;
}
.admin-stat-note {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 8px;
margin: 0;
color: #222222;
font-size: 12px;
line-height: 14px;
}
.admin-summary span {
padding: 6px 8px;
.admin-stat-note-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
white-space: nowrap;
}
/* ===========================
Main Grid / Section Windows
=========================== */
.admin-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.admin-span-2 {
grid-column: 1 / -1;
}
.admin-section-window {
min-height: 0;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
}
.admin-section-body {
margin: 0 6px 6px;
padding: 8px;
min-height: 0;
}
/* ===========================
Quick Actions
=========================== */
.admin-link-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
}
.admin-link-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
color: #000000;
font-size: 13px;
line-height: 13px;
}
.admin-link-button {
min-width: 112px;
height: 24px;
display: inline-grid;
place-items: center;
padding: 0 10px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-size: 12px;
line-height: 12px;
text-decoration: none;
}
.admin-link-button:hover {
filter: brightness(1.06);
}
/* Titlebar action links (Show all) */
.titlebar-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: 8px;
}
.titlebar-link-button {
height: 18px;
min-width: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
text-decoration: none;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
}
.titlebar-link-button:hover {
filter: brightness(1.08);
}
/* ===========================
Compact Mode
=========================== */
body.is-compact .admin-dashboard-body {
gap: 8px;
}
body.is-compact .admin-section-body {
padding: 5px;
}
/* ===========================
Responsive: Medium (tablets)
=========================== */
@media (max-width: 1180px) {
.admin-taskbar {
grid-template-columns: auto minmax(0, 1fr);
}
.admin-taskbar-session {
grid-column: 1 / -1;
justify-content: flex-start;
overflow-x: auto;
}
.admin-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-hero {
grid-template-columns: 1fr;
}
.admin-main-grid {
grid-template-columns: 1fr;
}
.admin-span-2 {
grid-column: auto;
}
}
/* ===========================
Responsive: Small (mobile)
=========================== */
@media (max-width: 760px) {
.admin-shell {
padding: 0 0 18px;
align-items: stretch;
}
.admin-frame {
width: 100%;
gap: 8px;
}
.admin-taskbar {
grid-template-columns: 1fr;
border-left: 0;
border-right: 0;
box-shadow: none;
}
.admin-start-button {
width: 100%;
justify-content: center;
}
.admin-taskbar-nav {
width: 100%;
overflow-x: auto;
padding-bottom: 3px;
}
.admin-taskbar-button {
min-width: 92px;
}
.admin-taskbar-session {
width: 100%;
overflow-x: auto;
padding-bottom: 3px;
}
.admin-session-chip,
.admin-alert-chip {
flex: 0 0 auto;
}
.admin-dashboard-window,
.admin-workspace-window {
min-height: 100dvh;
border-left: 0;
border-right: 0;
box-shadow: none;
}
.admin-dashboard-body {
padding: 6px;
gap: 8px;
}
.admin-stats-grid {
grid-template-columns: 1fr;
}
.admin-stat-card {
min-height: 112px;
}
.admin-menu-popup {
position: fixed;
left: 6px;
right: 6px;
top: 74px;
min-width: 0;
}
.titlebar-actions {
margin-left: 4px;
}
.titlebar-link-button {
min-width: 58px;
padding: 0 5px;
}
.admin-dashboard-statusbar {
grid-template-columns: 1fr;
height: auto;
min-height: 70px;
}
.win98-titlebar h1,
.win98-titlebar h2 {
font-size: 13px;
}
.win98-window-controls {
display: none;
}
}
/* Override global main layout on admin pages since admin uses its own shell */
body:has(.admin-shell) main {
display: contents;
}

394
static/css/alerts.css Normal file
View File

@@ -0,0 +1,394 @@
.alerts-page-body {
display: grid;
gap: 10px;
}
.alerts-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.alerts-stat-card {
min-width: 0;
padding: 8px;
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;
}
.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.alerts-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.alerts-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.alerts-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.alerts-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
gap: 10px;
min-height: 0;
}
.alerts-column {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.alerts-list-panel {
flex: 1 1 auto;
min-height: 520px;
}
.alerts-actions-panel {
flex: 1 1 auto;
min-height: 220px;
}
.alerts-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
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(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.alerts-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.alerts-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.alerts-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.alerts-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.alerts-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: auto;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.alerts-tool-button,
.alerts-row-button,
.alerts-footer-button {
min-width: 64px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.alerts-action-button {
width: 100%;
min-width: 0;
}
.alerts-toolbar-grid {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr));
gap: 8px;
margin-bottom: 8px;
}
.alerts-input,
.alerts-select,
.alerts-textarea {
width: 100%;
min-width: 0;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.alerts-input,
.alerts-select {
height: 28px;
}
.alerts-table-wrap {
height: 430px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.alerts-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
color: #000000;
}
.alerts-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 6px;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 0 1px 0 #ffffff;
}
.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.alerts-table tbody tr:hover { background: #d8e5f8; }
.alerts-table tbody tr.is-selected { background: #c5dcff; }
.alerts-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alerts-col-check { width: 34px; }
.alerts-col-severity { width: 76px; }
.alerts-col-status { width: 82px; }
.alerts-col-code { width: 70px; }
.alerts-col-time { width: 110px; }
.alerts-col-actions { width: 88px; }
.alerts-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.alerts-pill.low { background: #deebff; }
.alerts-pill.medium { background: #fff2c8; }
.alerts-pill.high { background: #ffdcdc; }
.alerts-pill.open { background: #f2e1ff; }
.alerts-pill.acked { background: #e2f0e2; }
.alerts-pill.closed { background: #ececec; }
.alerts-info-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.alerts-info-item {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 6px 8px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.alerts-info-item strong {
font-size: 13px;
line-height: 13px;
}
.alerts-info-item span {
min-width: 0;
color: #222222;
word-break: break-word;
}
.alerts-json-box {
max-height: 180px;
overflow: auto;
margin: 0;
padding: 8px;
color: #b7ffc8;
background: #050505;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
font-family: "MonoCraft", "Courier New", monospace;
font-size: 12px;
line-height: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.alerts-mini-note {
margin-top: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.alerts-action-stack {
display: grid;
gap: 8px;
}
.alerts-footerbar {
flex: 0 0 auto;
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-top: 1px solid #ffffff;
background: #dfdfdf;
}
.alerts-footer-left,
.alerts-footer-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.alerts-status-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
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;
}
@media (max-width: 1120px) {
.alerts-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.alerts-content-grid {
grid-template-columns: 1fr;
}
.alerts-toolbar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.alerts-summary-grid,
.alerts-toolbar-grid {
grid-template-columns: 1fr;
}
.alerts-table-wrap {
height: 360px;
}
.alerts-panel-header,
.alerts-footerbar {
align-items: flex-start;
flex-direction: column;
}
.alerts-info-item {
grid-template-columns: 1fr;
}
}

View File

@@ -109,7 +109,7 @@ textarea {
}
::-webkit-scrollbar-track {
background: repeating-linear-gradient(45deg, #c0c0c0 0 2px, #b5b5b5 2px 4px);
background: repeating-linear-gradient(45deg, #808080 0 2px, #8f8f8f 2px 4px);
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
@@ -126,8 +126,57 @@ textarea {
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
}
::-webkit-scrollbar-button:single-button {
width: 17px;
height: 17px;
background-color: #c0c0c0;
background-repeat: no-repeat;
background-position: center;
background-size: 7px 7px;
}
::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: linear-gradient(45deg, transparent 50%, #000000 50%), linear-gradient(135deg, #000000 50%, transparent 50%);
background-position: 5px 6px, 8px 6px;
background-size: 4px 4px, 4px 4px;
}
::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: linear-gradient(225deg, transparent 50%, #000000 50%), linear-gradient(315deg, #000000 50%, transparent 50%);
background-position: 5px 7px, 8px 7px;
background-size: 4px 4px, 4px 4px;
}
::-webkit-scrollbar-button:single-button:horizontal:decrement {
background-image: linear-gradient(135deg, transparent 50%, #000000 50%), linear-gradient(45deg, #000000 50%, transparent 50%);
background-position: 6px 5px, 6px 8px;
background-size: 4px 4px, 4px 4px;
}
::-webkit-scrollbar-button:single-button:horizontal:increment {
background-image: linear-gradient(315deg, transparent 50%, #000000 50%), linear-gradient(225deg, #000000 50%, transparent 50%);
background-position: 7px 5px, 7px 8px;
background-size: 4px 4px, 4px 4px;
}
::-webkit-scrollbar-thumb:hover,
::-webkit-scrollbar-button:single-button:hover {
background-color: #d0d0d0;
}
::-webkit-scrollbar-thumb:active,
::-webkit-scrollbar-button:single-button:active {
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
}
::-webkit-scrollbar-corner {
background: #c0c0c0;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
}
.win98-button {
@@ -231,10 +280,12 @@ textarea:disabled {
.popup-body li { margin: 0 0 4px; }
.popup-body .code-block {
margin: 6px 0 10px;
padding: 8px 8px 22px;
width: 100%;
max-width: 100%;
display: block;
overflow: auto;
overscroll-behavior: contain;
padding: 8px;
color: #00ff66;
background: #000000;
border: 0;
@@ -243,13 +294,20 @@ textarea:disabled {
line-height: 15px;
white-space: pre;
user-select: text;
-webkit-user-select: text;
cursor: text;
box-sizing: border-box;
contain: layout paint;
}
.popup-body .code-block::after {
content: "\A";
white-space: pre;
.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;
}
.copy-fallback-text {
@@ -329,6 +387,45 @@ textarea:disabled {
:root { --base-font-size: 18px; --ui-scale: 1.88; }
}
.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;
}
@keyframes start-ready-rainbow-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes start-border-rainbow-slide {
from { background-position: 0% 0%; }
to { background-position: 200% 0%; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

View File

@@ -284,6 +284,14 @@ body.fit-window .box-window {
white-space: pre;
}
.preview-frame.is-text code {
display: inline-block;
min-width: 100%;
color: inherit;
font: inherit;
white-space: inherit;
}
.box-empty {
margin: 0;
padding: 12px;

501
static/css/boxes.css Normal file
View File

@@ -0,0 +1,501 @@
.boxes-page-body {
display: grid;
gap: 10px;
}
.boxes-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.boxes-stat-card {
min-width: 0;
padding: 8px;
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;
}
.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.boxes-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.boxes-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.boxes-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.boxes-hero-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
}
.boxes-hero-note strong {
font-size: 13px;
line-height: 13px;
}
.boxes-hero-note span {
font-size: 13px;
line-height: 15px;
}
.boxes-hero-tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.boxes-hero-tag,
.boxes-flag {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.boxes-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr);
gap: 10px;
min-height: 0;
}
.boxes-column {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.boxes-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
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(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.boxes-files-panel {
min-height: 300px;
}
.boxes-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.boxes-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.boxes-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.boxes-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.boxes-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: hidden;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.boxes-tool-button,
.boxes-page-button,
.boxes-action-button,
.boxes-row-button {
min-width: 62px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.boxes-tool-button.is-danger,
.boxes-action-button.is-danger {
color: #ffffff;
background: #800000;
}
.boxes-toolbar-grid {
display: grid;
grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr));
gap: 8px;
margin-bottom: 8px;
}
.boxes-input,
.boxes-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.boxes-table-wrap {
width: 100%;
min-height: 0;
height: 460px;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.boxes-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
color: #000000;
}
.boxes-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 6px;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 0 1px 0 #ffffff;
}
.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.boxes-table tbody tr:hover { background: #d8e5f8; }
.boxes-table tbody tr.is-selected { background: #c5dcff; }
.boxes-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.boxes-col-check { width: 34px; }
.boxes-col-id { width: 190px; }
.boxes-col-status { width: 84px; }
.boxes-col-files { width: 58px; }
.boxes-col-size { width: 76px; }
.boxes-col-retention { width: 96px; }
.boxes-col-expires { width: 126px; }
.boxes-col-actions { width: 98px; }
.boxes-status-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.boxes-status-pill.ready { background: #def2e0; }
.boxes-status-pill.uploading { background: #fff1c9; }
.boxes-status-pill.attention { background: #ffe2bf; }
.boxes-status-pill.expired { background: #ffdcdc; }
.boxes-status-pill.consumed { background: #ead7ff; }
.boxes-status-pill.legacy { background: #ececec; }
.boxes-flags-cell,
.boxes-action-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
min-width: 0;
overflow: hidden;
}
.boxes-action-cell a {
text-decoration: none;
}
.boxes-empty-state {
padding: 24px 12px;
text-align: center;
color: #444444;
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95));
font-size: 14px;
line-height: 16px;
}
.boxes-footer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
font-size: 12px;
line-height: 12px;
}
.boxes-pagination {
display: flex;
align-items: center;
gap: 8px;
}
.boxes-detail-body {
display: grid;
gap: 10px;
}
.boxes-info-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.boxes-info-item {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 6px 8px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.boxes-info-item strong {
font-size: 13px;
line-height: 13px;
}
.boxes-info-item span {
min-width: 0;
color: #222222;
word-break: break-word;
}
.boxes-action-stack {
display: grid;
gap: 6px;
}
.boxes-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.boxes-action-button {
width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.boxes-file-list {
display: grid;
gap: 8px;
min-height: 0;
height: 320px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
.boxes-column:first-child > .boxes-panel {
flex: 1 1 auto;
}
.boxes-column:first-child > .boxes-panel > .boxes-panel-body {
display: flex;
flex-direction: column;
}
.boxes-column:first-child .boxes-table-wrap {
flex: 1 1 auto;
height: auto;
min-height: 560px;
}
.boxes-file-card {
display: grid;
gap: 6px;
padding: 8px;
background: #f8f8f8;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.boxes-file-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.boxes-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 13px;
font-weight: bold;
}
.boxes-file-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: #333333;
font-size: 12px;
line-height: 12px;
}
.boxes-file-link {
text-decoration: none;
}
@media (max-width: 1100px) {
.boxes-summary-grid,
.boxes-content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.boxes-column-side {
grid-column: 1 / -1;
}
.boxes-toolbar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.boxes-summary-grid,
.boxes-content-grid,
.boxes-toolbar-grid,
.boxes-action-grid {
grid-template-columns: minmax(0, 1fr);
}
.boxes-hero-note,
.boxes-footer-bar {
align-items: flex-start;
flex-direction: column;
}
.boxes-table-wrap {
height: 420px;
}
.boxes-column:first-child .boxes-table-wrap {
min-height: 420px;
}
}

View File

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

View File

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

289
static/css/dashboard.css Normal file
View File

@@ -0,0 +1,289 @@
/* ==============================================
Dashboard-specific styles (shared with admin)
Reusable across account dashboard pages
============================================== */
/* Hero section */
.dashboard-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 10px;
padding: 9px;
align-items: stretch;
}
.hero-copy h2 { margin: 0 0 5px; font-size: 22px; line-height: 24px; }
.hero-copy p { margin: 0; color: #333; font-size: 13px; line-height: 15px; }
.hero-status {
display: grid;
gap: 4px;
align-content: center;
padding: 7px;
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: 13px;
}
.hero-status-row { display: flex; justify-content: space-between; gap: 8px; }
.status-ok { color: #008000; }
.status-warn { color: #8a6200; }
.status-danger { color: #800000; }
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.stat-card {
position: relative;
min-height: 122px;
padding: 10px 11px 10px 14px;
overflow: hidden;
}
.stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 7px;
border-left: 7px solid #000078;
pointer-events: none;
}
.stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
.stat-card.is-ok::before { border-left-color: #008000; }
.stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
.stat-card.is-info::before { border-left-color: #000078; }
.stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
.stat-card.is-warning::before { border-left-color: #ffcc00; }
.stat-card.is-danger {
color: #000;
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
}
.stat-card.is-danger::before { border-left-color: #800000; }
.stat-label { margin: 0 0 6px; color: #333; font-size: 13px; line-height: 13px; font-weight: bold; }
.stat-value { margin: 0 0 7px; font-size: 32px; line-height: 32px; font-weight: bold; }
.stat-note { display: flex; gap: 4px; flex-wrap: wrap; margin: 0; color: #222; font-size: 12px; line-height: 14px; }
.stat-note-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
white-space: nowrap;
}
/* Main two-column grid */
.dashboard-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.dashboard-span-2 { grid-column: 1 / -1; }
/* Dashboard body */
.dashboard-body {
display: grid;
gap: 12px;
padding: 10px;
}
/* Section windows */
.section-window { min-height: 0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); }
.section-body { margin: 0 6px 6px; padding: 8px; min-height: 0; }
/* Scroll panels */
.scroll-panel { overflow: auto; background: #ffffff; border-top: 2px solid #606060; border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; }
.alerts-scroll { height: 326px; }
.boxes-scroll { height: 352px; }
.activity-scroll { height: 326px; }
/* Alerts */
.alert-list { display: grid; min-width: 0; }
.alert-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
min-height: 74px;
padding: 7px;
color: #000;
border-bottom: 1px solid #dfdfdf;
background: #ffffff;
}
.alert-row:nth-child(even) { background: #f5f8ff; }
.alert-row.is-dismissed { display: none; }
.alert-severity {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
min-height: 20px;
padding: 2px 5px;
text-transform: uppercase;
font-weight: bold;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.alert-row[data-severity="low"] .alert-severity { color: #000078; }
.alert-row[data-severity="medium"] .alert-severity { color: #8a6200; background: #ffffcc; }
.alert-row[data-severity="high"] .alert-severity { color: #ffffff; background: #800000; }
.alert-title { margin: 0 0 3px; font-weight: bold; font-size: 14px; line-height: 15px; }
.alert-desc { margin: 0 0 3px; color: #333; font-size: 12px; line-height: 14px; }
.alert-trace { margin: 0; color: #555; font-family: 'MonoCraft', 'Courier New', monospace; font-size: 10px; line-height: 13px; overflow-wrap: anywhere; }
.alert-actions { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
/* Boxes table */
.box-table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
color: #000;
font-size: 12px;
line-height: 14px;
}
.box-table th, .box-table td { padding: 6px 7px; border-bottom: 1px solid #dfdfdf; text-align: left; vertical-align: middle; }
.box-table th { position: sticky; top: 0; z-index: 5; background: #dfdfdf; border-bottom: 1px solid #808080; }
.box-table tr:nth-child(even) td { background: #f5f8ff; }
.box-actions { display: flex; gap: 5px; flex-wrap: nowrap; }
.box-action-button { min-width: 62px; height: 22px; padding: 0 6px; font-size: 12px; line-height: 12px; }
/* Activity */
.activity-list { display: grid; }
.activity-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 48px;
padding: 6px 8px;
border-bottom: 1px solid #dfdfdf;
background: #ffffff;
color: #000;
}
.activity-row:nth-child(even) { background: #f5f8ff; }
.activity-time { font-weight: bold; color: #000078; }
.activity-title { margin: 0 0 2px; font-weight: bold; }
.activity-meta { margin: 0; color: #555; font-size: 12px; line-height: 13px; }
/* Modal / Popup */
.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(calc(-50% - 1px), -50%);
width: min(760px, calc(100vw - 24px));
max-height: min(760px, calc(100vh - 24px));
display: none;
z-index: 80;
}
.popup-window.is-visible { display: flex; animation: popup-open 160ms steps(5, end); }
@keyframes popup-open {
from { transform: translate(calc(-50% - 1px), calc(-50% + 10px)) scale(.97); opacity: .45; }
to { transform: translate(calc(-50% - 1px), -50%) scale(1); opacity: 1; }
}
.popup-body { margin: 0 6px 6px; padding: 10px; max-height: calc(100vh - 90px); overflow: auto; color: #000; }
.metadata-pre {
min-height: 240px;
margin: 0;
padding: 10px;
overflow: auto;
color: #b7ffc8;
background: #030403;
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
font-family: 'MonoCraft', 'Courier New', monospace;
font-size: 12px;
line-height: 16px;
white-space: pre-wrap;
}
/* Tiny button (for alerts / boxes) */
.tiny-button {
min-width: 56px;
height: 22px;
display: inline-grid;
place-items: center;
padding: 0 7px;
color: #000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-size: 12px;
line-height: 12px;
text-decoration: none;
}
.tiny-button:hover { filter: brightness(1.06); }
/* Compact mode */
body.is-compact .dashboard-body { gap: 8px; }
body.is-compact .section-body { padding: 5px; }
body.is-compact .alerts-scroll,
body.is-compact .boxes-scroll { height: 280px; }
body.is-compact .activity-scroll { height: 280px; }
body.is-compact .alert-row { min-height: 62px; }
body.is-compact .activity-row { min-height: 42px; }
/* Responsive: medium */
@media (max-width: 1180px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-hero { grid-template-columns: 1fr; }
.dashboard-main-grid { grid-template-columns: 1fr; }
.dashboard-span-2 { grid-column: auto; }
.alerts-scroll, .boxes-scroll { height: 310px; }
.activity-scroll { height: 310px; }
}
/* Responsive: small (mobile) */
@media (max-width: 760px) {
.dashboard-body { padding: 6px; gap: 8px; }
.stats-grid { grid-template-columns: 1fr; }
.stat-card { min-height: 112px; }
.alert-row { grid-template-columns: 1fr; min-height: 0; }
.alert-actions { justify-content: flex-start; }
.alerts-scroll, .boxes-scroll, .activity-scroll { height: 320px; }
.boxes-scroll { overflow-x: auto; }
.activity-row { grid-template-columns: 48px minmax(0, 1fr); }
.activity-row .tag { grid-column: 2; justify-self: start; }
.popup-window {
left: 0;
top: 0;
transform: none;
width: 100vw;
height: 100dvh;
max-height: none;
border: 0;
box-shadow: none;
}
.popup-window.is-visible { animation: popup-open-mobile 150ms steps(5, end); }
@keyframes popup-open-mobile { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
.popup-body { max-height: calc(100dvh - 40px); }
}

516
static/css/settings.css Normal file
View File

@@ -0,0 +1,516 @@
.settings-page-body {
display: grid;
gap: 10px;
}
.settings-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.settings-stat-card {
min-width: 0;
padding: 8px;
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;
}
.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.settings-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.settings-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.settings-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.settings-main-grid {
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.settings-sidebar-panel {
min-width: 0;
}
.settings-sidebar {
position: sticky;
top: 48px;
}
.settings-workbench {
display: grid;
gap: 10px;
min-width: 0;
}
.settings-panel,
.settings-hero-panel {
min-width: 0;
background: #ffffff;
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(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.settings-panel {
display: flex;
flex-direction: column;
}
.settings-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.settings-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.settings-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.settings-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.settings-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: hidden;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.settings-hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr);
gap: 10px;
padding: 10px;
background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58));
}
.settings-hero-copy h2 {
margin: 0 0 6px;
font-size: 18px;
line-height: 18px;
}
.settings-hero-copy p {
margin: 0;
color: #222222;
font-size: 13px;
line-height: 16px;
}
.settings-hero-legend {
display: grid;
gap: 6px;
}
.settings-legend-row {
display: flex;
align-items: center;
gap: 6px;
color: #222222;
font-size: 12px;
line-height: 12px;
}
.settings-search {
display: grid;
gap: 6px;
margin-bottom: 8px;
}
.settings-search label {
font-weight: bold;
font-size: 13px;
line-height: 13px;
}
.settings-input,
.settings-select {
width: 100%;
min-width: 0;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.settings-input,
.settings-select {
height: 28px;
}
.settings-category-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.settings-category-button {
width: 100%;
min-height: 30px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding: 4px 6px;
color: #000000;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
font-family: inherit;
text-align: left;
}
.settings-category-button.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.settings-category-count,
.settings-dirty-chip,
.settings-badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.settings-category-button.is-active .settings-category-count {
color: #000000;
background: #ffffcc;
}
.settings-dirty-chip {
min-width: 78px;
justify-content: center;
}
.settings-dirty-chip.is-dirty {
background: #ffffcc;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
}
.badge-default { background: #ececec; }
.badge-env { background: #c7d8f2; }
.badge-db { background: #d2efcf; }
.badge-hard { background: #ffd9d9; }
.settings-tool-button,
.settings-mini-button,
.settings-popup-close {
min-width: 64px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.settings-action-summary {
margin-bottom: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.settings-groups {
display: grid;
gap: 10px;
min-height: 0;
max-height: 700px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
.settings-group {
display: grid;
gap: 0;
}
.settings-group[hidden] {
display: none;
}
.settings-group-title {
min-height: 28px;
padding: 6px 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;
font-weight: bold;
font-size: 14px;
line-height: 14px;
}
.settings-table-wrap {
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
background: #ffffff;
}
.settings-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
color: #000000;
font-size: 12px;
line-height: 14px;
}
.settings-table th,
.settings-table td {
padding: 6px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #e1e1e1;
}
.settings-table th {
position: sticky;
top: 0;
z-index: 2;
background: #dfdfdf;
box-shadow: inset 0 1px 0 #ffffff;
}
.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.setting-row.is-locked { color: #555555; background: #efefef; }
.setting-row.is-hidden { display: none; }
.setting-row.is-invalid { background: #fff1c9; }
.setting-meta {
display: grid;
gap: 4px;
}
.setting-meta strong {
font-size: 13px;
line-height: 13px;
}
.setting-meta code {
color: #1b325f;
font-size: 11px;
line-height: 12px;
word-break: break-word;
}
.setting-control {
display: grid;
gap: 4px;
}
.setting-input-row {
display: flex;
align-items: center;
gap: 6px;
}
.setting-hint {
color: #444444;
font-size: 11px;
line-height: 13px;
}
.setting-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.settings-modal-backdrop {
position: fixed;
inset: 0;
display: none;
background: rgba(0,0,0,.35);
z-index: 90;
}
.settings-modal-backdrop.is-visible {
display: block;
}
.settings-popup {
position: fixed;
left: 50%;
top: 50%;
width: min(520px, calc(100vw - 24px));
display: none;
transform: translate(-50%, -50%);
color: #000000;
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: 6px 6px 0 rgba(0,0,0,.35);
z-index: 95;
}
.settings-popup.is-visible {
display: block;
}
.settings-popup-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
padding: 4px 6px;
color: #ffffff;
background: #000078;
}
.settings-popup-body {
padding: 10px;
background: #f5f5f5;
font-size: 13px;
line-height: 16px;
}
.settings-popup-body p,
.settings-popup-body ul {
margin: 0 0 8px;
}
@media (max-width: 1100px) {
.settings-summary-grid,
.settings-main-grid,
.settings-hero-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-sidebar-panel,
.settings-workbench {
grid-column: 1 / -1;
}
.settings-sidebar {
position: static;
}
}
@media (max-width: 720px) {
.settings-summary-grid,
.settings-main-grid,
.settings-hero-panel {
grid-template-columns: minmax(0, 1fr);
}
.settings-panel-header {
align-items: flex-start;
flex-direction: column;
}
.settings-category-list {
grid-template-columns: minmax(0, 1fr);
}
.settings-table-wrap {
overflow-x: auto;
}
.settings-table {
min-width: 760px;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

309
static/css/users.css Normal file
View File

@@ -0,0 +1,309 @@
.users-page-body {
display: grid;
gap: 10px;
}
.users-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
gap: 10px;
padding: 10px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-hero h2 {
margin: 0 0 6px;
font-size: 24px;
line-height: 24px;
}
.users-hero p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 16px;
}
.users-hero-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.users-stat-card {
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.users-stat-card p {
margin: 0 0 6px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.users-stat-card strong {
font-size: 24px;
line-height: 24px;
}
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
gap: 10px;
min-height: 0;
}
.users-panel {
min-height: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-panel-header {
flex: 0 0 auto;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.users-panel-title span {
font-weight: normal;
color: #444444;
font-size: 12px;
line-height: 12px;
}
.users-panel-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.users-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.users-list-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
overflow: hidden;
}
.users-form-grid {
display: grid;
gap: 8px;
}
.users-row-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.users-field {
display: grid;
gap: 4px;
font-size: 12px;
line-height: 12px;
}
.users-input,
.users-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.users-check {
min-height: 20px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.users-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.users-action-button,
.users-tool-button,
.users-page-button {
min-width: 70px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.users-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.users-table th,
.users-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; }
.users-col-check { width: 30px; }
.users-col-actions { width: 136px; }
.users-username {
display: grid;
gap: 2px;
}
.users-username strong {
font-size: 13px;
line-height: 13px;
}
.users-muted {
color: #555555;
font-size: 11px;
line-height: 11px;
}
.users-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.users-pill.active { background: #def2e0; }
.users-pill.pending { background: #fff1c9; }
.users-pill.disabled { background: #ffdcdc; }
.users-row-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.users-row-button {
min-width: 60px;
height: 22px;
padding: 0 6px;
font-size: 12px;
line-height: 12px;
}
.users-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {
grid-template-columns: 1fr;
}
}

View File

@@ -128,3 +128,81 @@
font-size: 13px;
line-height: 13px;
}
/* Raised panel - appears to sit above the surface */
.raised-panel {
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;
}
/* Sunken panel - appears to be inset into the surface */
.sunken-panel {
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);
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
/* Scroll panel - used for scrollable content areas within windows */
.scroll-panel {
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
/* Meter track for progress bars */
.meter-track {
display: block;
height: 14px;
margin-top: 9px;
background-color: #ffffff;
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.06) 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;
}
.meter-bar {
display: block;
height: 100%;
width: var(--meter, 0%);
background-color: #000078;
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.13) 0 1px, transparent 1px 18px);
}
/* Tag styles for status indicators */
.tag {
display: inline-flex;
align-items: center;
min-height: 17px;
margin: 1px 2px 1px 0;
padding: 1px 5px;
color: #000000;
background: #dfdfdf;
border: 1px solid #808080;
box-shadow: inset 1px 1px 0 #ffffff;
white-space: nowrap;
}
.tag.ok { color: #008000; background: #eeffee; }
.tag.info { color: #000078; background: #edf4ff; }
.tag.warn { color: #8a6200; background: #ffffcc; }
.tag.danger { color: #ffffff; background: #800000; }
/* Titlebar animation - gradient drift */
@keyframes titlebar-drift {
from { background-position: 0% 50%; }
to { background-position: 100% 50%; }
}

216
static/js/admin/alerts.js Normal file
View File

@@ -0,0 +1,216 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const searchInput = document.getElementById("search-input");
const severityFilter = document.getElementById("severity-filter");
const statusFilter = document.getElementById("status-filter");
const sourceFilter = document.getElementById("source-filter");
const sortFilter = document.getElementById("sort-filter");
const alertsBody = document.getElementById("alerts-body");
const selectedCountEl = document.getElementById("selected-count");
const openCountEl = document.querySelector("[data-open-count]");
const highCountEl = document.querySelector("[data-high-count]");
const ackCountEl = document.querySelector("[data-ack-count]");
const closedCountEl = document.querySelector("[data-closed-count]");
const selectAll = document.getElementById("select-all");
const detailEls = {
title: document.getElementById("detail-title"),
severity: document.getElementById("detail-severity"),
status: document.getElementById("detail-status"),
code: document.getElementById("detail-code"),
trace: document.getElementById("detail-trace"),
time: document.getElementById("detail-time"),
description: document.getElementById("detail-description"),
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
function showToast(message, type = "info", duration = 1800) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast, duration });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
}
function selectedRows() {
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
}
function updateSelectedCount() {
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
}
function updateSummaryCounts() {
const rows = visibleRows();
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
}
function updateDetails(row) {
if (!row) return;
allRows().forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
detailEls.title.textContent = row.dataset.title || "";
detailEls.severity.textContent = row.dataset.severity || "";
detailEls.status.textContent = row.dataset.status || "";
detailEls.code.textContent = row.dataset.code || "";
detailEls.trace.textContent = row.dataset.trace || "";
detailEls.time.textContent = row.dataset.time || "";
detailEls.description.textContent = row.dataset.description || "";
try {
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
} catch (_) {
detailEls.metadata.textContent = row.dataset.metadata || "{}";
}
}
function applyFilters() {
const search = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.group
].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
const matchesStatus = status === "all" || row.dataset.status === status;
const matchesGroup = group === "all" || row.dataset.group === group;
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
});
const order = { high: 3, medium: 2, low: 1 };
visibleRows().sort((a, b) => {
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
return Number(b.dataset.id) - Number(a.dataset.id);
}).forEach((row) => alertsBody.appendChild(row));
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
updateSelectedCount();
updateSummaryCounts();
}
function setRowStatus(row, nextStatus) {
row.dataset.status = nextStatus;
const statusCell = row.children[3]?.querySelector(".alerts-pill");
if (!statusCell) return;
statusCell.className = `alerts-pill ${nextStatus}`;
statusCell.textContent = nextStatus;
}
function changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
showToast("Select one or more alerts first", "warning");
return;
}
rows.forEach((row) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
}
const commandMessages = {
refresh: "Alerts refreshed in mock view",
export: "Visible alerts exported in mock view",
"copy-meta": "Metadata copied in mock view",
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
"help-meta": "Metadata explains why the alert happened and includes extra context."
};
function runCommand(command) {
switch (command) {
case "ack":
changeSelectedStatus("acked");
return;
case "close":
changeSelectedStatus("closed");
return;
case "open-only":
statusFilter.value = "open";
applyFilters();
showToast("Showing open alerts only");
return;
default:
showToast(commandMessages[command] || `Mock action: ${command}`);
}
}
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
});
allRows().forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
updateDetails(row);
});
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectedCount();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
})();

526
static/js/admin/boxes.js Normal file
View File

@@ -0,0 +1,526 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toastTarget = document.getElementById("toast");
const dataNode = document.getElementById("boxes-data");
const tableBody = document.getElementById("boxes-table-body");
const emptyState = document.getElementById("boxes-empty-state");
const searchInput = document.getElementById("boxes-search");
const statusFilter = document.getElementById("boxes-status-filter");
const flagFilter = document.getElementById("boxes-flag-filter");
const sortFilter = document.getElementById("boxes-sort");
const pageSizeFilter = document.getElementById("boxes-page-size");
const selectAll = document.getElementById("boxes-select-all");
const prevPageButton = document.getElementById("boxes-prev-page");
const nextPageButton = document.getElementById("boxes-next-page");
const pageLabel = document.getElementById("boxes-page-label");
const rangeLabel = document.getElementById("boxes-range-label");
const selectedLabel = document.getElementById("boxes-selected-label");
const footerSummary = document.getElementById("boxes-footer-summary");
const detailFileList = document.getElementById("detail-file-list");
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
const statEls = {
total: document.querySelector("[data-stat-total]"),
ready: document.querySelector("[data-stat-ready]"),
uploading: document.querySelector("[data-stat-uploading]"),
expired: document.querySelector("[data-stat-expired]")
};
const detailEls = {
boxId: document.getElementById("detail-box-id"),
status: document.getElementById("detail-status"),
created: document.getElementById("detail-created"),
expires: document.getElementById("detail-expires"),
retention: document.getElementById("detail-retention"),
files: document.getElementById("detail-files"),
size: document.getElementById("detail-size"),
flags: document.getElementById("detail-flags"),
open: document.getElementById("detail-open"),
zip: document.getElementById("detail-zip")
};
function showToast(message, type = "info", duration = 2200) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
}
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
const state = {
boxes: parseData(),
selected: new Set(),
activeId: null,
page: 1
};
function pageSize() {
return Number(pageSizeFilter.value || 10);
}
function allBoxes() {
return state.boxes.slice();
}
function sortBoxes(boxes) {
const sorted = boxes.slice();
switch (sortFilter.value) {
case "name":
sorted.sort((a, b) => a.id.localeCompare(b.id));
break;
case "largest":
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
break;
case "expires":
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
break;
default:
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
}
return sorted;
}
function compareSizeLabel(left, right) {
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
}
function sizeLabelToBytes(label) {
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
if (!match) return 0;
const value = Number(match[1]);
const unit = match[2].toUpperCase();
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
return value * (map[unit] || 1);
}
function compareExpiry(left, right) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return left.localeCompare(right);
}
function filteredBoxes() {
const query = searchInput.value.trim().toLowerCase();
const status = statusFilter.value;
const flag = flagFilter.value;
return sortBoxes(allBoxes().filter((box) => {
const matchesSearch = !query || String(box.search_text || "").includes(query);
const matchesStatus = status === "all" || box.status === status;
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
return matchesSearch && matchesStatus && matchesFlag;
}));
}
function pagedBoxes(boxes) {
const size = pageSize();
const pages = Math.max(1, Math.ceil(boxes.length / size));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * size;
return {
items: boxes.slice(start, start + size),
start,
pages
};
}
function selectedBoxes() {
return allBoxes().filter((box) => state.selected.has(box.id));
}
function currentActiveBox() {
const boxes = allBoxes();
return boxes.find((box) => box.id === state.activeId) || null;
}
function ensureActiveBox(filtered) {
if (filtered.length === 0) {
state.activeId = null;
return null;
}
if (!filtered.some((box) => box.id === state.activeId)) {
state.activeId = filtered[0].id;
}
return filtered.find((box) => box.id === state.activeId) || filtered[0];
}
function renderSummary(filtered) {
const total = filtered.length;
const ready = filtered.filter((box) => box.status === "ready").length;
const uploading = filtered.filter((box) => box.status === "uploading").length;
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
statEls.total.textContent = String(total);
statEls.ready.textContent = String(ready);
statEls.uploading.textContent = String(uploading);
statEls.expired.textContent = String(expired);
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
selectedLabel.textContent = `Selected: ${state.selected.size}`;
}
function renderTable() {
const filtered = filteredBoxes();
const active = ensureActiveBox(filtered);
const page = pagedBoxes(filtered);
tableBody.innerHTML = "";
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
emptyState.hidden = page.items.length !== 0;
const startIndex = filtered.length ? page.start + 1 : 0;
const endIndex = page.start + page.items.length;
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
prevPageButton.disabled = state.page <= 1;
nextPageButton.disabled = state.page >= page.pages;
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
renderSummary(filtered);
renderDetails(active);
}
function buildRow(box) {
const row = document.createElement("tr");
if (box.id === state.activeId) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
<td title="${escapeAttr(box.id)}">${box.id}</td>
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
<td>${box.complete_files}/${box.file_count}</td>
<td>${box.total_size_label}</td>
<td>${box.retention_label || "Not set"}</td>
<td>${box.expires_at_label || "Not set"}</td>
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
`;
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
state.activeId = box.id;
renderTable();
});
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
if (event.target.checked) {
state.selected.add(box.id);
} else {
state.selected.delete(box.id);
}
selectedLabel.textContent = `Selected: ${state.selected.size}`;
syncSelectAllForPage();
});
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
state.activeId = box.id;
renderTable();
});
return row;
}
function renderFlags(flags) {
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
}
function renderRowActions(box) {
const parts = [
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
];
if (box.zip_available && box.zip_url) {
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
}
return parts.join("");
}
function renderDetails(box) {
if (!box) {
detailEls.boxId.textContent = "-";
detailEls.status.textContent = "-";
detailEls.created.textContent = "-";
detailEls.expires.textContent = "-";
detailEls.retention.textContent = "-";
detailEls.files.textContent = "-";
detailEls.size.textContent = "-";
detailEls.flags.textContent = "-";
detailEls.open.href = "#";
detailEls.zip.href = "#";
detailEls.zip.setAttribute("aria-disabled", "true");
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
return;
}
detailEls.boxId.textContent = box.id;
detailEls.status.textContent = box.status_label;
detailEls.created.textContent = box.created_at_label || "Not set";
detailEls.expires.textContent = box.expires_at_label || "Not set";
detailEls.retention.textContent = box.retention_label || "Not set";
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
detailEls.size.textContent = box.total_size_label;
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
detailEls.open.href = box.open_url || "#";
if (box.zip_available && box.zip_url) {
detailEls.zip.href = box.zip_url;
detailEls.zip.removeAttribute("aria-disabled");
detailEls.zip.style.pointerEvents = "";
detailEls.zip.style.opacity = "";
} else {
detailEls.zip.href = "#";
detailEls.zip.setAttribute("aria-disabled", "true");
detailEls.zip.style.pointerEvents = "none";
detailEls.zip.style.opacity = ".55";
}
renderFiles(box.files || []);
}
function renderFiles(files) {
if (!files.length) {
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
return;
}
detailFileList.innerHTML = files.map((file) => `
<div class="boxes-file-card">
<div class="boxes-file-row">
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
</div>
<div class="boxes-file-meta">
<span>${escapeHtml(file.size_label || "0 B")}</span>
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
</div>
</div>
`).join("");
}
function syncSelectAllForPage() {
const filtered = filteredBoxes();
const page = pagedBoxes(filtered);
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
selectedLabel.textContent = `Selected: ${state.selected.size}`;
}
function clearFilters() {
searchInput.value = "";
statusFilter.value = "all";
flagFilter.value = "all";
sortFilter.value = "newest";
pageSizeFilter.value = "10";
state.page = 1;
renderTable();
}
async function runBulkAction(action, ids, deltaSeconds = 0) {
if (!ids.length) {
showToast("Select one or more boxes first", "warning");
return;
}
try {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.error || payload.message || "Action failed";
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
showToast(`${message}${warning}`, "error", 3200);
return;
}
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
state.selected.clear();
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
state.activeId = null;
}
renderTable();
let message = payload.message || "Action complete";
if (Array.isArray(payload.warnings) && payload.warnings.length) {
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
}
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
} catch (_) {
showToast("Network error while updating boxes", "error", 3200);
}
}
function selectedIDsOrActive() {
if (state.selected.size) return Array.from(state.selected);
const active = currentActiveBox();
return active ? [active.id] : [];
}
async function runCommand(command) {
switch (command) {
case "refresh":
window.location.reload();
return;
case "export":
exportVisibleCSV();
showToast("Visible boxes exported");
return;
case "status-ready":
statusFilter.value = "ready";
state.page = 1;
renderTable();
return;
case "status-expired":
statusFilter.value = "expired";
state.page = 1;
renderTable();
return;
case "clear-filters":
clearFilters();
showToast("Filters cleared");
return;
case "expire":
case "active-expire":
await runBulkAction("expire", selectedIDsOrActive());
return;
case "extend-day":
case "active-extend-day":
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
return;
case "extend-week":
case "active-extend-week":
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
return;
case "delete":
case "active-delete":
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
await runBulkAction("delete", selectedIDsOrActive());
return;
case "help-scope":
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
return;
case "help-flags":
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
return;
default:
showToast(`Unknown command: ${command}`, "warning");
}
}
function exportVisibleCSV() {
const rows = filteredBoxes().map((box) => ([
box.id,
box.status_label,
box.file_count,
box.total_size_label,
box.retention_label,
box.expires_at_label,
(box.flags || []).join("|")
]));
const csv = [
["box_id", "status", "files", "size", "retention", "expires", "flags"],
...rows
].map((row) => row.map(csvCell).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "warpbox-boxes.csv";
anchor.click();
URL.revokeObjectURL(url);
}
function csvCell(value) {
const text = String(value ?? "");
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
return text;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("'", "&#39;");
}
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
renderTable();
});
});
pageSizeFilter.addEventListener("change", () => {
state.page = 1;
renderTable();
});
selectAll?.addEventListener("change", () => {
const filtered = filteredBoxes();
const page = pagedBoxes(filtered);
page.items.forEach((box) => {
if (selectAll.checked) state.selected.add(box.id);
else state.selected.delete(box.id);
});
renderTable();
});
prevPageButton?.addEventListener("click", () => {
state.page -= 1;
renderTable();
});
nextPageButton?.addEventListener("click", () => {
state.page += 1;
renderTable();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
await runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
await runCommand("refresh");
}
});
if (state.boxes.length > 0) {
state.activeId = state.boxes[0].id;
}
renderTable();
})();

View File

@@ -0,0 +1,201 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const statusText = document.getElementById("statusText");
const modal = document.querySelector("[data-alert-modal]");
const backdrop = document.querySelector("[data-modal-backdrop]");
const modalTitle = document.getElementById("modalTitle");
const modalMeta = document.getElementById("modalMeta");
const alertCountValue = document.getElementById("alertCountValue");
const alertStatNote = document.getElementById("alertStatNote");
const alertsCard = document.getElementById("alertsCard");
const topAlertChip = document.getElementById("topAlertChip");
const topTaskbar = document.querySelector(".admin-taskbar");
if (!statusText || !alertsCard || !topAlertChip) return;
function showToast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600);
}
function setStatus(message) {
statusText.textContent = message;
}
function openModal(title, meta) {
if (!modal || !backdrop || !modalTitle || !modalMeta) return;
modalTitle.textContent = title;
modalMeta.textContent = meta;
modal.classList.add("is-visible");
modal.setAttribute("aria-hidden", "false");
backdrop.classList.add("is-visible");
}
function closeModal() {
modal?.classList.remove("is-visible");
modal?.setAttribute("aria-hidden", "true");
backdrop?.classList.remove("is-visible");
}
function visibleAlertRows() {
return Array.from(document.querySelectorAll(".alert-row")).filter((row) => !row.classList.contains("is-dismissed"));
}
function updateStickyHeader() {
topTaskbar?.classList.toggle("is-scrolled", window.scrollY > 4);
}
function updateAlertSummary() {
const rows = visibleAlertRows();
const counts = rows.reduce((acc, row) => {
const severity = row.dataset.severity || "low";
acc[severity] = (acc[severity] || 0) + 1;
return acc;
}, { high: 0, medium: 0, low: 0 });
const score = counts.high * 5 + counts.medium * 2 + counts.low;
const total = rows.length;
const stateClass = counts.high > 0 || score >= 12 ? "is-danger" : counts.medium >= 2 || score >= 5 ? "is-warning" : total > 0 ? "is-info" : "is-ok";
alertsCard.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
alertsCard.classList.add(stateClass);
topAlertChip.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
topAlertChip.classList.add(stateClass);
if (alertCountValue) alertCountValue.textContent = String(total);
topAlertChip.textContent = total === 0 ? "OK no alerts" : `! ${total} alerts`;
if (alertStatNote) {
alertStatNote.innerHTML = total === 0
? '<span class="stat-note-pill">all clear</span>'
: `<span class="stat-note-pill">${counts.high} high</span><span class="stat-note-pill">${counts.medium} medium</span><span class="stat-note-pill">${counts.low} low</span>`;
}
}
function scrollToSection(id) {
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
setStatus(`Focused ${id.replace("-", " ")}`);
}
const commandMessages = {
refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.",
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.",
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.",
"compact-mode": "Toggled compact density.",
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.",
"show-all-alerts": "TO-DO: navigate to /admin/alerts.",
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.",
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
"dismiss-low-alerts": "Closed visible low-severity alerts in this mock.",
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."
};
function runCommand(command) {
if (command === "compact-mode") document.body.classList.toggle("is-compact");
if (command === "dismiss-low-alerts") {
document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed"));
updateAlertSummary();
}
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
if (command === "show-all-alerts") window.location.hash = "alerts";
const message = commandMessages[command] || `Command: ${command}`;
showToast(message);
setStatus(message);
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.querySelectorAll("[data-scroll-to]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
scrollToSection(button.dataset.scrollTo);
});
});
document.querySelectorAll("[data-view-meta]").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest(".alert-row");
const title = row?.dataset.alertTitle || "Alert Metadata";
let meta = row?.dataset.alertMeta || "{}";
try {
meta = JSON.stringify(JSON.parse(meta), null, 2);
} catch (_) {
meta = row?.dataset.alertMeta || "{}";
}
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta);
});
});
document.querySelectorAll("[data-dismiss-alert]").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest(".alert-row");
row?.classList.add("is-dismissed");
updateAlertSummary();
showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`);
setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`);
});
});
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
backdrop?.addEventListener("click", closeModal);
topAlertChip.addEventListener("click", (event) => {
event.preventDefault();
scrollToSection("alerts");
});
window.addEventListener("scroll", updateStickyHeader, { passive: true });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menuController.close();
closeModal();
}
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
if (event.altKey && event.key.toLowerCase() === "a") {
event.preventDefault();
scrollToSection("alerts");
}
if (event.altKey && event.key.toLowerCase() === "b") {
event.preventDefault();
scrollToSection("recent-boxes");
}
if (event.altKey && event.key.toLowerCase() === "r") {
event.preventDefault();
scrollToSection("recent-activity");
}
});
updateAlertSummary();
updateStickyHeader();
})();

459
static/js/admin/settings.js Normal file
View File

@@ -0,0 +1,459 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const rowsNode = document.getElementById("settings-rows");
const searchInput = document.getElementById("settingsSearch");
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
const groups = Array.from(document.querySelectorAll(".settings-group"));
const saveButton = document.getElementById("saveButton");
const exportButton = document.getElementById("exportButton");
const importButton = document.getElementById("importButton");
const resetButton = document.getElementById("resetButton");
const importInput = document.getElementById("settingsImportInput");
const dirtyChip = document.getElementById("dirtyChip");
const actionSummary = document.getElementById("actionSummary");
const visibleCount = document.getElementById("visibleCount");
const editableCount = document.getElementById("editableCount");
const unsavedCount = document.getElementById("unsavedCount");
const lockedCount = document.getElementById("lockedCount");
const statusLeft = document.getElementById("statusLeft");
const statusMiddle = document.getElementById("statusMiddle");
const statusRight = document.getElementById("statusRight");
const popupClose = document.getElementById("doc-popup-close");
const toastTarget = document.getElementById("toast");
if (!rowsNode || !searchInput || !saveButton) return;
const state = {
currentCategory: "all",
showChangedOnly: false,
showLockedOnly: false
};
function parseRows() {
try {
return JSON.parse(rowsNode.textContent || "[]");
} catch (_) {
return [];
}
}
const rowData = parseRows().reduce((map, row) => {
map[row.key] = row;
return map;
}, {});
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
element: row,
input: row.querySelector(".setting-input"),
hint: row.querySelector('[data-role="hint"]'),
badge: row.querySelector('[data-role="source-badge"]'),
key: row.dataset.key,
label: row.dataset.label,
category: row.dataset.category,
envName: row.dataset.envName,
type: row.dataset.type,
minimum: Number(row.dataset.minimum || 0),
locked: row.classList.contains("is-locked")
}));
function showToast(message, type = "info", duration = 2400) {
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
function currentValue(row) {
if (!row.input) return row.element.dataset.original || "";
return String(row.input.value ?? "").trim();
}
function isDirty(row) {
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
}
function validateRow(row) {
if (row.locked || !row.input) {
row.element.classList.remove("is-invalid");
return true;
}
const value = currentValue(row);
let valid = true;
if (row.type === "size_gb") {
if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false;
else if (Number(value) < row.minimum) valid = false;
} else if (row.type === "int" || row.type === "int64") {
if (!/^\d+$/.test(value)) valid = false;
else if (Number(value) < row.minimum) valid = false;
} else if (row.type === "bool") {
valid = value === "true" || value === "false";
}
row.element.classList.toggle("is-invalid", !valid);
return valid;
}
function rowMatchesSearch(row) {
const query = searchInput.value.trim().toLowerCase();
if (!query) return true;
const data = [
row.label,
row.envName,
row.element.dataset.description,
row.key
].join(" ").toLowerCase();
return data.includes(query);
}
function applyFilters() {
let visible = 0;
groups.forEach((group) => {
let groupVisible = 0;
group.querySelectorAll(".setting-row").forEach((node) => {
const row = rows.find((item) => item.element === node);
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
const searchMatch = rowMatchesSearch(row);
const changedMatch = !state.showChangedOnly || isDirty(row);
const lockedMatch = !state.showLockedOnly || row.locked;
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
node.classList.toggle("is-hidden", !show);
if (show) {
visible += 1;
groupVisible += 1;
}
});
group.hidden = groupVisible === 0;
});
visibleCount.textContent = String(visible);
statusMiddle.textContent = `category: ${state.currentCategory}`;
}
function updateStats() {
let dirty = 0;
let editable = 0;
let locked = 0;
let invalid = 0;
rows.forEach((row) => {
if (row.locked) locked += 1;
else editable += 1;
if (isDirty(row)) dirty += 1;
if (!validateRow(row)) invalid += 1;
});
editableCount.textContent = String(editable);
lockedCount.textContent = String(locked);
unsavedCount.textContent = String(dirty);
dirtyChip.textContent = `${dirty} unsaved`;
dirtyChip.classList.toggle("is-dirty", dirty > 0);
saveButton.disabled = dirty === 0 || invalid > 0;
if (invalid > 0) {
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
statusLeft.textContent = "Invalid values";
statusRight.textContent = "fix before save";
} else if (dirty > 0) {
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
statusLeft.textContent = "Unsaved changes";
statusRight.textContent = "draft ready";
} else {
actionSummary.textContent = "No unsaved changes.";
statusLeft.textContent = "No unsaved changes";
statusRight.textContent = "admin only";
}
}
function updateView() {
updateStats();
applyFilters();
}
function setCategory(category) {
state.currentCategory = category;
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
applyFilters();
}
function draftValues() {
const values = {};
rows.forEach((row) => {
if (!row.locked && isDirty(row)) values[row.key] = currentValue(row);
});
return values;
}
function updateRowFromPayload(payload) {
const row = rows.find((item) => item.key === payload.key);
if (!row) return;
row.element.dataset.original = payload.value;
row.element.dataset.default = payload.default_value || "";
row.element.dataset.source = payload.source || "default";
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
row.element.dataset.description = payload.description || "";
row.element.dataset.minimum = String(payload.minimum || 0);
row.element.classList.toggle("is-locked", Boolean(payload.locked));
row.locked = Boolean(payload.locked);
row.minimum = Number(payload.minimum || 0);
if (row.input) {
row.input.value = payload.value ?? "";
row.input.disabled = Boolean(payload.locked);
}
if (row.hint) {
row.hint.textContent = payload.locked
? "Locked by environment or hard runtime implication."
: payload.type === "size_gb"
? "Use GB values. Decimals allowed, for example `0.5`."
: (payload.default_value ? `Default: ${payload.default_value}` : "");
}
if (row.badge) {
row.badge.textContent = payload.source_badge || payload.source || "default";
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
}
rowData[payload.key] = payload;
}
function badgeClass(source) {
if (source === "default") return "badge-default";
if (source === "environment") return "badge-env";
if (source === "db override") return "badge-db";
return "badge-hard";
}
function hydrateRows(payloadRows) {
if (!Array.isArray(payloadRows)) return;
payloadRows.forEach(updateRowFromPayload);
updateView();
}
async function postJSON(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "Request failed");
}
return payload;
}
async function saveChanges() {
const values = draftValues();
if (Object.keys(values).length === 0) {
showToast("No changed settings to save", "info");
return;
}
try {
const payload = await postJSON("/admin/settings/save", { values });
hydrateRows(payload.rows);
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function resetDefaults() {
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
try {
const payload = await postJSON("/admin/settings/reset", {});
hydrateRows(payload.rows);
showToast(payload.message || "Defaults restored", "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function resetSingleSetting(row) {
if (row.locked || !row.input) return;
if (isDirty(row)) {
row.input.value = row.element.dataset.original || "";
updateView();
showToast(`${row.label} draft cleared`);
return;
}
try {
const payload = await postJSON("/admin/settings/reset", { keys: [row.key] });
hydrateRows(payload.rows);
showToast(`${row.label} reset`, "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function exportSettings() {
try {
const response = await fetch("/admin/settings/export");
if (!response.ok) throw new Error("Could not export settings");
const payload = await response.json();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
showToast("Settings JSON exported");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function importSettingsFile(file) {
if (!file) return;
try {
const text = await file.text();
const payload = JSON.parse(text);
const result = await postJSON("/admin/settings/import", payload);
hydrateRows(result.rows);
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
} catch (error) {
showToast(error.message || "Could not import settings JSON", "error", 3200);
} finally {
importInput.value = "";
}
}
function discardUnsaved() {
rows.forEach((row) => {
if (!row.input) return;
row.input.value = row.element.dataset.original || "";
});
updateView();
showToast("Unsaved changes discarded");
}
function explainSources() {
window.WarpBoxUI?.openPopup?.(
"Setting Sources",
`
<ul>
<li><strong>default</strong>: built-in application value.</li>
<li><strong>environment</strong>: loaded from an environment variable.</li>
<li><strong>db override</strong>: saved from the admin settings page.</li>
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
</ul>
`
);
}
function explainReset() {
window.WarpBoxUI?.openPopup?.(
"Reset Behavior",
`
<p>Reset clears saved admin overrides.</p>
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</p>
`
);
}
function showRowInfo(row) {
window.WarpBoxUI?.openPopup?.(
row.label,
`
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
`
);
}
async function runCommand(command) {
switch (command) {
case "save":
await saveChanges();
return;
case "export":
await exportSettings();
return;
case "import":
importInput.click();
return;
case "discard":
discardUnsaved();
return;
case "show-all":
state.showChangedOnly = false;
state.showLockedOnly = false;
applyFilters();
showToast("Showing all matching settings");
return;
case "show-changed":
state.showChangedOnly = !state.showChangedOnly;
if (state.showChangedOnly) state.showLockedOnly = false;
applyFilters();
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
return;
case "show-locked":
state.showLockedOnly = !state.showLockedOnly;
if (state.showLockedOnly) state.showChangedOnly = false;
applyFilters();
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
return;
case "reset-defaults":
await resetDefaults();
return;
case "reload":
window.location.reload();
return;
case "legend":
explainSources();
return;
case "reset-help":
explainReset();
return;
default:
showToast(`Unknown command: ${command}`, "warning");
}
}
rows.forEach((row) => {
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
row.element.querySelector(".row-reset")?.addEventListener("click", () => resetSingleSetting(row));
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
});
searchInput.addEventListener("input", applyFilters);
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
saveButton.addEventListener("click", saveChanges);
exportButton.addEventListener("click", exportSettings);
importButton.addEventListener("click", () => importInput.click());
resetButton.addEventListener("click", resetDefaults);
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
await runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", async (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
await saveChanges();
}
if (event.key === "F5") {
event.preventDefault();
window.location.reload();
}
if (event.key === "Escape") {
menuController.close();
window.WarpBoxUI?.closePopup?.();
}
});
updateView();
})();

304
static/js/admin/users.js Normal file
View File

@@ -0,0 +1,304 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body");
const search = document.getElementById("users-search");
const status = document.getElementById("users-status");
const role = document.getElementById("users-role-filter");
const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check");
const pageInfo = document.getElementById("users-page-info");
const visiblePill = document.getElementById("visible-pill");
const selectedPill = document.getElementById("users-selected-pill");
const prevBtn = document.getElementById("users-prev");
const nextBtn = document.getElementById("users-next");
const selectVisible = document.getElementById("select-visible");
const form = document.getElementById("users-form");
const modeInput = document.getElementById("users-mode");
const usernameInput = document.getElementById("users-username");
const emailInput = document.getElementById("users-email");
const roleInput = document.getElementById("users-role");
const planInput = document.getElementById("users-plan");
const statusLeft = document.getElementById("users-status-left");
if (!body || !search || !status || !role || !sort || !size) return;
const users = [
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
];
const state = { page: 1, selected: new Set() };
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
}
function filtered() {
const query = search.value.trim().toLowerCase();
const statusFilter = status.value;
const roleFilter = role.value;
const sortBy = sort.value;
const rows = users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
const matchesRole = roleFilter === "all" || user.role === roleFilter;
return matchesQuery && matchesStatus && matchesRole;
});
rows.sort((a, b) => {
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
return a.username.localeCompare(b.username);
});
return rows;
}
function paged(rows) {
const perPage = Number(size.value || 12);
const pages = Math.max(1, Math.ceil(rows.length / perPage));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start };
}
function statusPill(value) {
return `<span class="users-pill ${value}">${value}</span>`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const row = document.createElement("tr");
row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td>
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
<td title="${user.email}">${user.email}</td>
<td>${statusPill(user.status)}</td>
<td>${user.role}</td>
<td>${user.plan}</td>
<td>${user.boxes}</td>
<td>${user.lastSeen}</td>
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
`;
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
syncSelected();
syncMasterCheck();
});
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
toast(`Mock user preview: ${user.username}`);
});
return row;
}
function syncSelected() {
selectedPill.textContent = `${state.selected.size} selected`;
}
function syncMasterCheck() {
const checks = Array.from(body.querySelectorAll(".row-check"));
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
}
function renderStats() {
document.getElementById("stat-total").textContent = String(users.length);
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
}
function render() {
const rows = filtered();
const page = paged(rows);
body.innerHTML = "";
page.rows.forEach((user) => body.appendChild(renderRow(user)));
visiblePill.textContent = `${rows.length} visible`;
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= page.pages;
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
syncSelected();
syncMasterCheck();
}
function clearFilters() {
search.value = "";
status.value = "all";
role.value = "all";
sort.value = "username";
state.page = 1;
render();
}
function applyBulk(nextStatus) {
const selected = users.filter((user) => state.selected.has(user.id));
if (!selected.length) {
toast("Select one or more users first", "warning");
return;
}
selected.forEach((user) => { user.status = nextStatus; });
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
renderStats();
render();
}
function runCommand(command) {
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break;
case "create":
modeInput.value = "create";
toast("Create mode selected");
break;
case "export":
toast("Mock CSV export complete");
break;
case "bulk-disable":
applyBulk("disabled");
break;
case "bulk-enable":
applyBulk("active");
break;
case "bulk-revoke":
toast("Mock session revocation queued");
break;
case "refresh":
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
state.page = 1;
render();
break;
case "clear-filters":
clearFilters();
break;
case "policy-help":
toast("Policy editor will be added in user details later.");
break;
case "mock-note":
toast("Mock-only page: no backend writes yet.");
break;
default:
toast(`Mock action: ${command}`);
break;
}
}
[search, status, role, sort, size].forEach((el) => {
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
render();
});
});
prevBtn.addEventListener("click", () => {
state.page -= 1;
render();
});
nextBtn.addEventListener("click", () => {
state.page += 1;
render();
});
masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (!checkbox) return;
checkbox.checked = masterCheck.checked;
const userID = row.querySelector(".users-muted")?.textContent || "";
if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID);
});
syncSelected();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || "";
if (!checkbox) return;
checkbox.checked = true;
state.selected.add(userID);
});
syncSelected();
syncMasterCheck();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const mode = modeInput.value;
if (!username || !email) {
toast("Username and email are required", "warning");
return;
}
users.unshift({
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
username,
email,
status: mode === "invite" ? "pending" : "active",
role: roleInput.value,
plan: planInput.value,
boxes: 0,
created: new Date().toISOString().slice(0, 10),
lastSeen: "never"
});
form.reset();
modeInput.value = "invite";
renderStats();
render();
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
event.preventDefault();
runCommand("invite");
}
});
renderStats();
render();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -13,15 +13,9 @@ const toast = document.querySelector("#toast");
const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
let contextFile = null;
let lastStatusSignature = "";
function htmlEscape(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
const htmlEscape = window.WarpBoxUI.htmlEscape;
function showToast(message, type = "info") {
window.WarpBoxUI.toast(message, type, { target: toast });
@@ -149,7 +143,7 @@ async function previewFile(item) {
const response = await fetch(url);
if (!response.ok) throw new Error("Preview failed");
const text = await response.text();
openPopup(`${data.name} preview`, `<code class="code-block preview-frame is-text">${htmlEscape(text.slice(0, 120000))}</code>`, { preview: true });
openPopup(`${data.name} preview`, `<pre class="code-block preview-frame is-text"><code>${htmlEscape(text.slice(0, 120000))}</code></pre>`, { preview: true });
} catch (_) {
showToast("The browser could not load a text preview.", "error");
}
@@ -214,9 +208,13 @@ async function refreshBoxStatus() {
const boxID = boxPanel.dataset.boxId;
const response = await fetch(`/box/${boxID}/status`);
if (!response.ok) return true;
if (!response.ok) return { changed: false, hasLoadingFiles: true };
const result = await response.json();
const signature = statusSignature(result);
const changed = signature !== lastStatusSignature;
lastStatusSignature = signature;
if (boxExpiryMeta && typeof result.expires_at === "string") {
boxExpiryMeta.dataset.expiresAt = result.expires_at;
updateExpiryCountdown();
@@ -228,30 +226,108 @@ async function refreshBoxStatus() {
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
}
return result.files.some((file) => {
const hasLoadingFiles = result.files.some((file) => {
const isUploading = file.status === "pending" || file.status === "uploading";
const isWaitingForThumbnail = file.status === "complete" && !file.thumbnail_status && !file.thumbnail_path;
return isUploading || isWaitingForThumbnail || file.thumbnail_status === "processing";
});
return { changed, hasLoadingFiles };
}
function statusSignature(result) {
const files = Array.isArray(result.files) ? result.files : [];
return JSON.stringify({
expiresAt: result.expires_at || "",
files: files.map((file) => ({
id: file.id,
status: file.status,
size: file.size,
thumbnailPath: file.thumbnail_path || "",
thumbnailStatus: file.thumbnail_status || "",
downloadPath: file.download_path || "",
})),
});
}
function pollingStages(baseMS) {
return [
{ interval: baseMS, attempts: 10 },
{ interval: baseMS * 2, attempts: 20 },
{ interval: baseMS * 10, attempts: 100 },
];
}
function startStagedPolling(baseMS) {
const stages = pollingStages(baseMS);
let stageIndex = 0;
let attemptsInStage = 0;
let stopped = false;
const tick = async () => {
if (stopped) return;
const stage = stages[stageIndex];
try {
const result = await refreshBoxStatus();
if (result.changed) {
stageIndex = 0;
attemptsInStage = 0;
} else {
attemptsInStage += 1;
if (attemptsInStage >= stage.attempts) {
stageIndex += 1;
attemptsInStage = 0;
if (stageIndex >= stages.length) {
stopped = true;
return;
}
}
}
} catch (_) {
attemptsInStage += 1;
}
if (!stopped) {
window.setTimeout(tick, stages[stageIndex].interval);
}
};
window.setTimeout(tick, stages[0].interval);
}
function runBoxAction(action) {
const actions = {
"fake-close": () => showToast("Close clicked. The download window is emotionally attached.", "warning"),
minimize: () => showToast("Minimize clicked. WarpBox refuses to disappear quietly."),
"toggle-fit": () => {
document.body.classList.toggle("fit-window");
showToast("Maximize clicked. The window is doing its best.");
},
};
actions[action]?.();
}
function runContextAction(action, item) {
const actions = {
preview: () => previewFile(item),
download: () => downloadFile(item),
properties: () => showProperties(item),
};
actions[action]?.();
}
document.addEventListener("click", (event) => {
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;
}
@@ -296,12 +372,5 @@ setInterval(updateExpiryCountdown, 1000);
if (boxPanel) {
const pollMS = Number.parseInt(boxPanel.dataset.pollMs, 10) || 5000;
const timer = setInterval(async () => {
try {
const hasLoadingFiles = await refreshBoxStatus();
if (!hasLoadingFiles) clearInterval(timer);
} catch (_) {
// Keep polling through temporary network/server hiccups.
}
}, pollMS);
startStagedPolling(pollMS);
}

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More