Compare commits
28 Commits
698166d23d
...
old-admin
| Author | SHA1 | Date | |
|---|---|---|---|
| 82d4dc815b | |||
| 89c885f637 | |||
| e103829870 | |||
| 2714907ff4 | |||
| b8bb75f7e0 | |||
| b0bdf798a9 | |||
| 877ac90574 | |||
| f0b723e35d | |||
| a729b641b2 | |||
| 7d70a0c2ed | |||
| 6b9f6ac291 | |||
| 0f630b9dca | |||
| 903b4eeed8 | |||
| ac6e8c591b | |||
| fb80f11e72 | |||
| e330fb04b3 | |||
| a8c0666b5a | |||
| 6035ea1eb2 | |||
| 82acaffdd8 | |||
| cb026d4fd1 | |||
| a5d6d69be0 | |||
| fc3de58b5b | |||
| 9dececcc7d | |||
| f1600faa8d | |||
| c1489d1fbb | |||
| 041a9798a7 | |||
| 2f37958c31 | |||
| cf90e08f98 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
docs/
|
||||
memory-bank/
|
||||
*_test.go
|
||||
README.md
|
||||
run.sh
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
data/
|
||||
27
.env.example
Normal file
27
.env.example
Normal 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
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -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
41
CODE_OF_CONDUCT.md
Normal 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
125
CONTRIBUTING.md
Normal 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
183
DEVELOPMENT.md
Normal 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.
|
||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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/main.go
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# 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_MB=2048 \
|
||||
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \
|
||||
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
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
CMD ["./warpbox", "run", "--addr", ":8080"]
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal 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
4
NOTICE
Normal file
@@ -0,0 +1,4 @@
|
||||
WarpBox
|
||||
Copyright (c) 2026 Daniel Legt
|
||||
|
||||
This product includes software developed by Daniel Legt.
|
||||
191
README.md
191
README.md
@@ -0,0 +1,191 @@
|
||||
# WarpBox
|
||||
|
||||
WarpBox is a small, self-hosted file sharing app with temporary upload boxes,
|
||||
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
|
||||
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
|
||||
|
||||
- Multi-file uploads through a browser UI.
|
||||
- Temporary boxes with configurable retention choices.
|
||||
- Optional password protection per box.
|
||||
- Individual file downloads or a single ZIP download.
|
||||
- One-time download mode for ZIP-only handoff.
|
||||
- Background thumbnails for image files.
|
||||
- Plain filesystem storage, with JSON manifests next to uploaded files.
|
||||
- Local BadgerDB metadata store for users, tags, sessions, and settings.
|
||||
- No external database service required.
|
||||
|
||||
## How It Fits Together
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Browser[Browser UI]
|
||||
Server[Go HTTP server]
|
||||
Manifest[Box manifest JSON]
|
||||
Files[Uploaded files]
|
||||
Thumbs[Generated thumbnails]
|
||||
DB[(BadgerDB metadata)]
|
||||
|
||||
Browser -->|POST /box, uploads, status polls| Server
|
||||
Server --> Manifest
|
||||
Server --> Files
|
||||
Server --> Thumbs
|
||||
Server --> DB
|
||||
Thumbs -->|preview URLs| Browser
|
||||
Files -->|downloads / ZIP| Browser
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Requirements:
|
||||
|
||||
- Go 1.22 or newer.
|
||||
|
||||
Run the app:
|
||||
|
||||
```bash
|
||||
go run ./cmd run
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
To listen somewhere else:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| --- | ---: | --- |
|
||||
| `WARPBOX_DATA_DIR` | `./data` | Root directory for uploads and metadata. |
|
||||
| `WARPBOX_ADMIN_PASSWORD` | empty | Bootstraps the first admin when set. |
|
||||
| `WARPBOX_ADMIN_USERNAME` | `admin` | Bootstrap admin username. |
|
||||
| `WARPBOX_ADMIN_EMAIL` | empty | Bootstrap admin email. |
|
||||
| `WARPBOX_ADMIN_ENABLED` | `auto` | Admin login mode: `auto`, `true`, or `false`. |
|
||||
| `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE` | `true` | Allows safe settings overrides from `/admin/settings`. |
|
||||
| `WARPBOX_ADMIN_COOKIE_SECURE` | `false` | Sets the Secure flag on admin session cookies. |
|
||||
| `WARPBOX_GUEST_UPLOADS_ENABLED` | `true` | Enables guest uploads. |
|
||||
| `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_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. |
|
||||
|
||||
Size limits also accept `_MB` variants for the same settings.
|
||||
|
||||
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 \
|
||||
go run ./cmd run --addr :8080
|
||||
```
|
||||
|
||||
Open `/admin/login` after startup to sign in with the bootstrap admin.
|
||||
|
||||
## Storage
|
||||
|
||||
Uploads are stored locally under:
|
||||
|
||||
```text
|
||||
<WARPBOX_DATA_DIR>/uploads/
|
||||
```
|
||||
|
||||
Each box gets its own directory containing the uploaded files and a
|
||||
`.warpbox.json` manifest. Image thumbnails are stored inside a box-local
|
||||
`.thumbnails` directory.
|
||||
|
||||
Persistent app metadata lives in BadgerDB under:
|
||||
|
||||
```text
|
||||
<WARPBOX_DATA_DIR>/db/
|
||||
```
|
||||
|
||||
```text
|
||||
data/uploads/
|
||||
+-- <box-id>/
|
||||
+-- .warpbox.json
|
||||
+-- file.txt
|
||||
+-- .thumbnails/
|
||||
+-- <file-id>.jpg
|
||||
data/db/
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
|
||||
```text
|
||||
cmd/ CLI entrypoint
|
||||
lib/server/ HTTP handlers and server setup
|
||||
lib/routing/ Route registration
|
||||
lib/boxstore/ Box storage, manifests, downloads, thumbnails
|
||||
lib/config/ Typed environment and runtime settings config
|
||||
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/ 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 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).
|
||||
|
||||
2
TRADEMARK.md
Normal file
2
TRADEMARK.md
Normal 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
19
check.sh
Executable 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
554
cmd/cmd_box.go
Normal 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
|
||||
}
|
||||
271
cmd/cmd_env.go
Normal file
271
cmd/cmd_env.go
Normal file
@@ -0,0 +1,271 @@
|
||||
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"},
|
||||
}
|
||||
|
||||
sizePairs := []struct {
|
||||
bytesEnv string
|
||||
mbEnv string
|
||||
label string
|
||||
}{
|
||||
{"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "Global max file size"},
|
||||
{"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "Global max box size"},
|
||||
{"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "Default user max file size"},
|
||||
{"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "Default user max box size"},
|
||||
}
|
||||
|
||||
for _, pair := range sizePairs {
|
||||
extra = append(extra, envRow{EnvName: pair.bytesEnv, Key: pair.bytesEnv, Label: pair.label + " (bytes)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
|
||||
extra = append(extra, envRow{EnvName: pair.mbEnv, Key: pair.mbEnv, Label: pair.label + " (MB)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
|
||||
}
|
||||
|
||||
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
181
cmd/cmd_format.go
Normal 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
21
cmd/cmd_run.go
Normal 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
|
||||
}
|
||||
17
cmd/main.go
17
cmd/main.go
@@ -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
|
||||
}
|
||||
|
||||
11
docker-compose.example.yml
Normal file
11
docker-compose.example.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
warpbox:
|
||||
image: warpbox:latest
|
||||
container_name: warpbox
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
194
docs/tech.md
Normal file
194
docs/tech.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# WarpBox Tech Stack
|
||||
|
||||
This document is a light technical map of WarpBox. It avoids deep internals,
|
||||
but should be enough to understand what the project is built from and where the
|
||||
main pieces live.
|
||||
|
||||
## Backend
|
||||
|
||||
WarpBox is written in Go.
|
||||
|
||||
Main libraries:
|
||||
|
||||
- `github.com/gin-gonic/gin` for HTTP routing, middleware, JSON responses, and
|
||||
HTML template rendering.
|
||||
- `github.com/gin-contrib/gzip` for compressed static asset responses.
|
||||
- `github.com/spf13/cobra` for the small command-line interface.
|
||||
|
||||
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.
|
||||
|
||||
- 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, 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 for box data and BadgerDB for app metadata.
|
||||
|
||||
Uploaded boxes are stored under:
|
||||
|
||||
```text
|
||||
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. BadgerDB stores users, tags, sessions,
|
||||
and runtime settings overrides.
|
||||
|
||||
## Upload Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser as Browser UI
|
||||
participant Server as Gin server
|
||||
participant Store as boxstore
|
||||
participant Disk as Local disk
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Shared boxes are served from `/box/:id`.
|
||||
|
||||
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
|
||||
boxes, finds complete image files without thumbnails, generates small JPEG
|
||||
previews, and updates the manifest.
|
||||
|
||||
Tuning is done with:
|
||||
|
||||
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
||||
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
||||
|
||||
## Configuration
|
||||
|
||||
Runtime configuration is centralized in `lib/config`. Startup applies built-in
|
||||
defaults, environment variables, then safe BadgerDB settings overrides.
|
||||
|
||||
Storage paths are derived from `WARPBOX_DATA_DIR`:
|
||||
|
||||
```text
|
||||
<WARPBOX_DATA_DIR>/uploads
|
||||
<WARPBOX_DATA_DIR>/db
|
||||
```
|
||||
|
||||
The admin account is bootstrapped from `WARPBOX_ADMIN_PASSWORD` when no admin
|
||||
user exists. If the password is empty, admin login stays disabled unless an
|
||||
admin user already exists in BadgerDB.
|
||||
|
||||
Primary environment variables:
|
||||
|
||||
- `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`
|
||||
|
||||
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED`
|
||||
accepts `auto`, `true`, or `false`.
|
||||
|
||||
The HTTP listen address is configured through the CLI flag:
|
||||
|
||||
```bash
|
||||
go run ./cmd run --addr :8080
|
||||
```
|
||||
|
||||
## Code Map
|
||||
|
||||
```text
|
||||
cmd/main.go CLI setup
|
||||
lib/server/server.go Gin engine setup and worker startup
|
||||
lib/server/handlers.go HTTP handlers
|
||||
lib/server/admin.go Admin handlers
|
||||
lib/routing/routes.go Route table
|
||||
lib/boxstore/store.go Box manifests, uploads, downloads, retention
|
||||
lib/boxstore/thumbnails.go
|
||||
Thumbnail scanning and generation
|
||||
lib/config/config.go Typed config and settings definitions
|
||||
lib/metastore/ BadgerDB metadata store
|
||||
lib/models/models.go Shared data structures
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
Existing tests cover config, storage, server security, and metastore behavior.
|
||||
Run them with:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
31
go.mod
31
go.mod
@@ -1,44 +1,53 @@
|
||||
module warpbox
|
||||
|
||||
go 1.22
|
||||
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.8.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
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/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/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // 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/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // 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/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
73
go.sum
73
go.sum
@@ -2,15 +2,24 @@ 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.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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=
|
||||
@@ -19,6 +28,11 @@ 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=
|
||||
@@ -29,22 +43,23 @@ 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/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
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=
|
||||
@@ -58,17 +73,15 @@ 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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -86,23 +99,29 @@ 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=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
222
lib/boxstore/files.go
Normal file
222
lib/boxstore/files.go
Normal 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
33
lib/boxstore/icons.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IconForMimeType(mimeType string, filename string) string {
|
||||
extension := strings.ToLower(filepath.Ext(filename))
|
||||
|
||||
switch {
|
||||
case extension == ".exe":
|
||||
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
|
||||
case strings.HasPrefix(mimeType, "image/"):
|
||||
return "/static/img/sprites/bitmap.png"
|
||||
case strings.HasPrefix(mimeType, "video/"):
|
||||
return "/static/img/icons/netshow_notransm-1.png"
|
||||
case strings.HasPrefix(mimeType, "audio/"):
|
||||
return "/static/img/icons/netshow_notransm-1.png"
|
||||
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
|
||||
return "/static/img/sprites/notepad_file-1.png"
|
||||
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
|
||||
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
|
||||
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
|
||||
return "/static/img/sprites/font.png"
|
||||
case extension == ".pdf":
|
||||
return "/static/img/sprites/journal.png"
|
||||
case extension == ".html" || extension == ".css" || extension == ".js":
|
||||
return "/static/img/sprites/frame_web-0.png"
|
||||
default:
|
||||
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
|
||||
}
|
||||
}
|
||||
220
lib/boxstore/manifest.go
Normal file
220
lib/boxstore/manifest.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
var manifestMu sync.Mutex
|
||||
|
||||
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
|
||||
retention := normalizeRetentionOption(request.RetentionKey)
|
||||
usedNames := make(map[string]int, len(request.Files))
|
||||
files := make([]models.BoxFile, 0, len(request.Files))
|
||||
|
||||
for _, fileRequest := range request.Files {
|
||||
filename, ok := helpers.SafeFilename(fileRequest.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid filename")
|
||||
}
|
||||
|
||||
filename = helpers.UniqueNameInBatch(filename, usedNames)
|
||||
fileID, err := helpers.RandomHexID(8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not create file id")
|
||||
}
|
||||
|
||||
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
files = append(files, models.BoxFile{
|
||||
ID: fileID,
|
||||
Name: filename,
|
||||
Size: fileRequest.Size,
|
||||
MimeType: mimeType,
|
||||
Status: models.FileStatusWait,
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
disableZip := false
|
||||
if request.AllowZip != nil {
|
||||
disableZip = !*request.AllowZip
|
||||
}
|
||||
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
|
||||
if oneTimeDownload {
|
||||
disableZip = false
|
||||
}
|
||||
|
||||
manifest := models.BoxManifest{
|
||||
Files: files,
|
||||
CreatedAt: now,
|
||||
RetentionKey: retention.Key,
|
||||
RetentionLabel: retention.Label,
|
||||
RetentionSecs: retention.Seconds,
|
||||
DisableZip: disableZip,
|
||||
OneTimeDownload: oneTimeDownload,
|
||||
}
|
||||
|
||||
if password := strings.TrimSpace(request.Password); password != "" {
|
||||
authToken, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not secure upload box")
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Could not secure upload box")
|
||||
}
|
||||
|
||||
manifest.PasswordHash = string(passwordHash)
|
||||
manifest.PasswordHashAlg = "bcrypt"
|
||||
manifest.AuthToken = authToken
|
||||
}
|
||||
|
||||
if err := WriteManifest(boxID, manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decoratedFiles := make([]models.BoxFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file))
|
||||
}
|
||||
|
||||
return decoratedFiles, nil
|
||||
}
|
||||
func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) {
|
||||
if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed {
|
||||
return models.BoxFile{}, fmt.Errorf("Invalid file status")
|
||||
}
|
||||
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return models.BoxFile{}, err
|
||||
}
|
||||
|
||||
for index, file := range manifest.Files {
|
||||
if file.ID != fileID {
|
||||
continue
|
||||
}
|
||||
|
||||
manifest.Files[index].Status = status
|
||||
startRetentionIfTerminalUnlocked(&manifest)
|
||||
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
||||
return models.BoxFile{}, err
|
||||
}
|
||||
|
||||
return DecorateFile(boxID, manifest.Files[index]), nil
|
||||
}
|
||||
|
||||
return models.BoxFile{}, fmt.Errorf("File not found")
|
||||
}
|
||||
|
||||
func ReadManifest(boxID string) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
return readManifestUnlocked(boxID)
|
||||
}
|
||||
|
||||
func WriteManifest(boxID string, manifest models.BoxManifest) error {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
return writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
|
||||
return manifest, nil
|
||||
}
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for index, file := range manifest.Files {
|
||||
path, ok := SafeBoxFilePath(boxID, file.Name)
|
||||
if !ok || ensureRegularFile(path) != nil {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
if file.Status == models.FileStatusReady && file.Size == info.Size() {
|
||||
continue
|
||||
}
|
||||
|
||||
// The manifest is the UI source of truth, but disk wins when an upload
|
||||
// was saved and the final status write/response was interrupted.
|
||||
manifest.Files[index].Size = info.Size()
|
||||
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
|
||||
manifest.Files[index].Status = models.FileStatusReady
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
startRetentionIfTerminalUnlocked(&manifest)
|
||||
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
|
||||
var manifest models.BoxManifest
|
||||
data, err := os.ReadFile(ManifestPath(boxID))
|
||||
if err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return manifest, err
|
||||
}
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// Manifest writes are serialized because the browser can upload several files
|
||||
// concurrently into the same box. Without this lock, status updates can race.
|
||||
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(ManifestPath(boxID), data, 0644)
|
||||
}
|
||||
79
lib/boxstore/paths.go
Normal file
79
lib/boxstore/paths.go
Normal 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
74
lib/boxstore/retention.go
Normal 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
51
lib/boxstore/security.go
Normal 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[:])
|
||||
}
|
||||
206
lib/boxstore/store_test.go
Normal file
206
lib/boxstore/store_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func TestStartRetentionWaitsForEveryFileToFinish(t *testing.T) {
|
||||
manifest := models.BoxManifest{
|
||||
RetentionSecs: 10,
|
||||
Files: []models.BoxFile{
|
||||
{ID: "one", Status: models.FileStatusReady},
|
||||
{ID: "two", Status: models.FileStatusWork},
|
||||
},
|
||||
}
|
||||
|
||||
startRetentionIfTerminalUnlocked(&manifest)
|
||||
|
||||
if !manifest.ExpiresAt.IsZero() {
|
||||
t.Fatalf("expected retention to stay unset while a file is still uploading, got %s", manifest.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
|
||||
manifest := models.BoxManifest{
|
||||
RetentionSecs: 10,
|
||||
Files: []models.BoxFile{
|
||||
{ID: "one", Status: models.FileStatusReady},
|
||||
{ID: "two", Status: models.FileStatusFailed},
|
||||
},
|
||||
}
|
||||
before := time.Now().UTC()
|
||||
|
||||
startRetentionIfTerminalUnlocked(&manifest)
|
||||
|
||||
if manifest.ExpiresAt.IsZero() {
|
||||
t.Fatal("expected retention to start once every file is complete or failed")
|
||||
}
|
||||
if manifest.ExpiresAt.Before(before.Add(9 * time.Second)) {
|
||||
t.Fatalf("expected retention to start from completion time, got %s", manifest.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRetentionUsesConfiguredOneTimeDownloadExpiry(t *testing.T) {
|
||||
restoreExpiry := OneTimeDownloadExpiry()
|
||||
defer SetOneTimeDownloadExpiry(restoreExpiry)
|
||||
SetOneTimeDownloadExpiry(30)
|
||||
|
||||
manifest := models.BoxManifest{
|
||||
RetentionSecs: 10,
|
||||
OneTimeDownload: true,
|
||||
Files: []models.BoxFile{
|
||||
{ID: "one", Status: models.FileStatusReady},
|
||||
{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 zero one-time expiry to keep expiry unset, got %s", manifest.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeBoxFilePathRejectsTraversal(t *testing.T) {
|
||||
restoreUploadRoot := UploadRoot()
|
||||
defer SetUploadRoot(restoreUploadRoot)
|
||||
SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "0123456789abcdef0123456789abcdef"
|
||||
if _, ok := SafeBoxFilePath(boxID, "../outside.txt"); ok {
|
||||
t.Fatal("expected traversal to be rejected")
|
||||
}
|
||||
if _, ok := SafeBoxFilePath("../bad", "file.txt"); ok {
|
||||
t.Fatal("expected invalid box id to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFileToZipRejectsUnsafeManifestName(t *testing.T) {
|
||||
restoreUploadRoot := UploadRoot()
|
||||
defer SetUploadRoot(restoreUploadRoot)
|
||||
SetUploadRoot(t.TempDir())
|
||||
|
||||
var buffer bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buffer)
|
||||
if err := AddFileToZip(zipWriter, "0123456789abcdef0123456789abcdef", "../outside.txt"); err == nil {
|
||||
t.Fatal("expected unsafe zip filename to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesSkipsSymlinks(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 := os.WriteFile(filepath.Join(BoxPath(boxID), "safe.txt"), []byte("safe"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(BoxPath(boxID), "safe.txt"), filepath.Join(BoxPath(boxID), "link.txt")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
files, err := ListFiles(boxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles returned error: %v", err)
|
||||
}
|
||||
if len(files) != 1 || files[0].Name != "safe.txt" {
|
||||
t.Fatalf("expected only regular file, got %#v", files)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "0123456789abcdef0123456789abcdef"
|
||||
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
if _, err := CreateManifest(boxID, models.CreateBoxRequest{Password: "secret"}); err != nil {
|
||||
t.Fatalf("CreateManifest returned error: %v", err)
|
||||
}
|
||||
manifest, err := ReadManifest(boxID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.PasswordHashAlg != "bcrypt" {
|
||||
t.Fatalf("expected bcrypt password hash, got %q", manifest.PasswordHashAlg)
|
||||
}
|
||||
if !VerifyPassword(manifest, "secret") {
|
||||
t.Fatal("expected bcrypt password to verify")
|
||||
}
|
||||
|
||||
legacy := models.BoxManifest{
|
||||
PasswordSalt: "salt",
|
||||
PasswordHash: legacyPasswordHash("salt", "secret"),
|
||||
AuthToken: "token",
|
||||
}
|
||||
if !VerifyPassword(legacy, "secret") {
|
||||
t.Fatal("expected legacy password hash to verify")
|
||||
}
|
||||
}
|
||||
74
lib/boxstore/summary.go
Normal file
74
lib/boxstore/summary.go
Normal 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
|
||||
}
|
||||
267
lib/boxstore/thumbnails.go
Normal file
267
lib/boxstore/thumbnails.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package boxstore
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbnailDir = ".thumbnails"
|
||||
thumbnailMaxSize = 160
|
||||
)
|
||||
|
||||
type thumbnailTask struct {
|
||||
BoxID string
|
||||
FileID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func StartThumbnailWorker(batchSize int, interval time.Duration) {
|
||||
if batchSize < 1 {
|
||||
batchSize = 10
|
||||
}
|
||||
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
ProcessThumbnailBatch(batchSize)
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func ProcessThumbnailBatch(batchSize int) int {
|
||||
tasks := collectThumbnailTasks(batchSize)
|
||||
for _, task := range tasks {
|
||||
if err := generateThumbnail(task); err != nil {
|
||||
markThumbnailFailed(task.BoxID, task.FileID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return len(tasks)
|
||||
}
|
||||
|
||||
func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
|
||||
if !helpers.ValidLowerHexID(fileID, 16) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return helpers.SafeChildPath(filepath.Join(BoxPath(boxID), thumbnailDir), fileID+".jpg")
|
||||
}
|
||||
|
||||
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
||||
entries, err := os.ReadDir(uploadRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks := make([]thumbnailTask, 0, batchSize)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
tasks = append(tasks, collectBoxThumbnailTasks(entry.Name(), batchSize-len(tasks))...)
|
||||
if len(tasks) >= batchSize {
|
||||
return tasks
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil || IsExpired(manifest) || manifest.OneTimeDownload {
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks := make([]thumbnailTask, 0, remaining)
|
||||
changed := false
|
||||
for index, file := range manifest.Files {
|
||||
if len(tasks) >= remaining {
|
||||
break
|
||||
}
|
||||
|
||||
if file.Status != models.FileStatusReady || file.ThumbnailPath != nil || file.ThumbnailStatus != "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !canGenerateThumbnail(file) {
|
||||
manifest.Files[index].ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
tasks = append(tasks, thumbnailTask{
|
||||
BoxID: boxID,
|
||||
FileID: file.ID,
|
||||
Name: file.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if changed {
|
||||
writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func canGenerateThumbnail(file models.BoxFile) bool {
|
||||
if strings.HasPrefix(file.MimeType, "image/") {
|
||||
return true
|
||||
}
|
||||
|
||||
extension := strings.ToLower(filepath.Ext(file.Name))
|
||||
return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif"
|
||||
}
|
||||
|
||||
func generateThumbnail(task thumbnailTask) error {
|
||||
sourcePath, ok := SafeBoxFilePath(task.BoxID, task.Name)
|
||||
if !ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
if err := ensureRegularFile(sourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
src, _, err := image.Decode(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thumb := resizeImage(src, thumbnailMaxSize)
|
||||
if err := os.MkdirAll(filepath.Join(BoxPath(task.BoxID), thumbnailDir), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, ok := ThumbnailFilePath(task.BoxID, task.FileID)
|
||||
if !ok {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
target, tempPath, err := createTempSibling(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
target.Close()
|
||||
if !committed {
|
||||
os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := target.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
|
||||
return markThumbnailReady(task.BoxID, task.FileID)
|
||||
}
|
||||
|
||||
func resizeImage(src image.Image, maxSize int) image.Image {
|
||||
bounds := src.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return src
|
||||
}
|
||||
|
||||
targetWidth := width
|
||||
targetHeight := height
|
||||
if width > maxSize || height > maxSize {
|
||||
if width >= height {
|
||||
targetWidth = maxSize
|
||||
targetHeight = height * maxSize / width
|
||||
} else {
|
||||
targetHeight = maxSize
|
||||
targetWidth = width * maxSize / height
|
||||
}
|
||||
}
|
||||
|
||||
if targetWidth < 1 {
|
||||
targetWidth = 1
|
||||
}
|
||||
if targetHeight < 1 {
|
||||
targetHeight = 1
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
for y := 0; y < targetHeight; y++ {
|
||||
for x := 0; x < targetWidth; x++ {
|
||||
srcX := bounds.Min.X + x*width/targetWidth
|
||||
srcY := bounds.Min.Y + y*height/targetHeight
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func markThumbnailReady(boxID string, fileID string) error {
|
||||
path := "/box/" + boxID + "/thumbnails/" + url.PathEscape(fileID)
|
||||
return updateThumbnailState(boxID, fileID, &path, models.ThumbnailStatusReady)
|
||||
}
|
||||
|
||||
func markThumbnailFailed(boxID string, fileID string) {
|
||||
updateThumbnailState(boxID, fileID, nil, models.ThumbnailStatusFailed)
|
||||
}
|
||||
|
||||
func updateThumbnailState(boxID string, fileID string, thumbnailPath *string, status string) error {
|
||||
manifestMu.Lock()
|
||||
defer manifestMu.Unlock()
|
||||
|
||||
manifest, err := readManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for index, file := range manifest.Files {
|
||||
if file.ID != fileID {
|
||||
continue
|
||||
}
|
||||
|
||||
manifest.Files[index].ThumbnailPath = thumbnailPath
|
||||
manifest.Files[index].ThumbnailStatus = status
|
||||
return writeManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
return os.ErrNotExist
|
||||
}
|
||||
50
lib/boxstore/zip.go
Normal file
50
lib/boxstore/zip.go
Normal 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
|
||||
}
|
||||
210
lib/config/config_test.go
Normal file
210
lib/config/config_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.UploadsDir != filepath.Join("data", "uploads") {
|
||||
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||
}
|
||||
if cfg.DBDir != filepath.Join("data", "db") {
|
||||
t.Fatalf("unexpected db dir: %s", cfg.DBDir)
|
||||
}
|
||||
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
||||
t.Fatal("expected default guest/API/download toggles to be enabled")
|
||||
}
|
||||
if cfg.AdminUsername != "admin" {
|
||||
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||
}
|
||||
if cfg.AdminPassword != "" {
|
||||
t.Fatal("expected default admin password to be empty")
|
||||
}
|
||||
if !cfg.BoxOwnerEditEnabled || !cfg.BoxOwnerRefreshEnabled || !cfg.BoxOwnerPasswordEditEnabled {
|
||||
t.Fatal("expected box owner policy defaults to be enabled")
|
||||
}
|
||||
if cfg.BoxOwnerMaxRefreshCount != 3 || cfg.BoxOwnerMaxRefreshAmountSeconds != 86400 || cfg.BoxOwnerMaxTotalExpirySeconds != 604800 {
|
||||
t.Fatalf("unexpected box owner policy defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverrides(t *testing.T) {
|
||||
clearConfigEnv(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_BOX_POLL_INTERVAL_MS", "2000")
|
||||
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||
t.Setenv("WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", "5")
|
||||
t.Setenv("WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", "false")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.UploadsDir != filepath.Join("/tmp/warpbox-test", "uploads") {
|
||||
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||
}
|
||||
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
||||
t.Fatal("expected boolean environment overrides to be applied")
|
||||
}
|
||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
if cfg.BoxPollIntervalMS != 2000 {
|
||||
t.Fatalf("unexpected poll interval: %d", cfg.BoxPollIntervalMS)
|
||||
}
|
||||
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.BoxOwnerMaxRefreshCount != 5 || cfg.BoxOwnerPasswordEditEnabled {
|
||||
t.Fatal("expected box owner policy env overrides to be applied")
|
||||
}
|
||||
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
|
||||
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidEnvironmentValues(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_SESSION_TTL_SECONDS", "1")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid session ttl to fail")
|
||||
}
|
||||
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "maybe")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid boolean to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsOverridePrecedence(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_API_ENABLED", "true")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if err := cfg.ApplyOverrides(map[string]string{SettingAPIEnabled: "false"}); err != nil {
|
||||
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.APIEnabled {
|
||||
t.Fatal("expected DB override to beat environment value")
|
||||
}
|
||||
if cfg.Source(SettingAPIEnabled) != SourceDB {
|
||||
t.Fatalf("expected DB source, got %s", cfg.Source(SettingAPIEnabled))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsOverrideValidation(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
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(SettingBoxOwnerMaxRefreshCount, "2"); err != nil {
|
||||
t.Fatalf("expected box owner policy override to pass: %v", err)
|
||||
}
|
||||
if cfg.BoxOwnerMaxRefreshCount != 2 {
|
||||
t.Fatalf("expected box owner policy override to apply, got %d", cfg.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
}
|
||||
|
||||
func clearConfigEnv(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_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_MB",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||
"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",
|
||||
"WARPBOX_BOX_OWNER_EDIT_ENABLED",
|
||||
"WARPBOX_BOX_OWNER_REFRESH_ENABLED",
|
||||
"WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT",
|
||||
"WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS",
|
||||
"WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS",
|
||||
"WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
79
lib/config/definitions.go
Normal file
79
lib/config/definitions.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
var Definitions = []SettingDefinition{
|
||||
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
||||
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
|
||||
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingBoxOwnerEditEnabled, EnvName: "WARPBOX_BOX_OWNER_EDIT_ENABLED", Label: "Box owner edit enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingBoxOwnerRefreshEnabled, EnvName: "WARPBOX_BOX_OWNER_REFRESH_ENABLED", Label: "Box owner refresh enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingBoxOwnerMaxRefreshCount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", Label: "Box owner max refresh count", Type: SettingTypeInt, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerMaxRefreshAmount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", Label: "Box owner max refresh amount seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerMaxTotalExpiry, EnvName: "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", Label: "Box owner max total expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerPasswordEdit, EnvName: "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", Label: "Box owner password edit enabled", Type: SettingTypeBool, Editable: true},
|
||||
}
|
||||
|
||||
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) SettingValue(key string) string {
|
||||
return cfg.values[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
|
||||
}
|
||||
280
lib/config/load.go
Normal file
280
lib/config/load.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DataDir: "./data",
|
||||
AdminUsername: "admin",
|
||||
AdminEnabled: AdminEnabledAuto,
|
||||
AllowAdminSettingsOverride: true,
|
||||
GuestUploadsEnabled: true,
|
||||
APIEnabled: true,
|
||||
ZipDownloadsEnabled: true,
|
||||
OneTimeDownloadsEnabled: true,
|
||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||
OneTimeDownloadRetryOnFailure: false,
|
||||
DefaultGuestExpirySeconds: 10,
|
||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||
SessionTTLSeconds: 24 * 60 * 60,
|
||||
BoxPollIntervalMS: 5000,
|
||||
ThumbnailBatchSize: 10,
|
||||
ThumbnailIntervalSeconds: 30,
|
||||
BoxOwnerEditEnabled: true,
|
||||
BoxOwnerRefreshEnabled: true,
|
||||
BoxOwnerMaxRefreshCount: 3,
|
||||
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
|
||||
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
|
||||
BoxOwnerPasswordEditEnabled: true,
|
||||
sources: make(map[string]Source),
|
||||
values: make(map[string]string),
|
||||
}
|
||||
|
||||
// Config precedence: defaults -> env -> overrides.
|
||||
// Overrides are applied after Load by the server once the metadata store opens.
|
||||
cfg.captureDefaults()
|
||||
|
||||
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
|
||||
}
|
||||
cfg.AdminEnabled = mode
|
||||
}
|
||||
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
envBools := []struct {
|
||||
key string
|
||||
name string
|
||||
target *bool
|
||||
}{
|
||||
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
|
||||
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
|
||||
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
|
||||
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
|
||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||
{SettingBoxOwnerEditEnabled, "WARPBOX_BOX_OWNER_EDIT_ENABLED", &cfg.BoxOwnerEditEnabled},
|
||||
{SettingBoxOwnerRefreshEnabled, "WARPBOX_BOX_OWNER_REFRESH_ENABLED", &cfg.BoxOwnerRefreshEnabled},
|
||||
{SettingBoxOwnerPasswordEdit, "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", &cfg.BoxOwnerPasswordEditEnabled},
|
||||
}
|
||||
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},
|
||||
{SettingBoxOwnerMaxRefreshAmount, "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", 0, &cfg.BoxOwnerMaxRefreshAmountSeconds},
|
||||
{SettingBoxOwnerMaxTotalExpiry, "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", 0, &cfg.BoxOwnerMaxTotalExpirySeconds},
|
||||
}
|
||||
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},
|
||||
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
|
||||
}
|
||||
for _, item := range envInts {
|
||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cfg.DataDir = filepath.Clean(cfg.DataDir)
|
||||
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
|
||||
cfg.DataDir = "data"
|
||||
}
|
||||
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
|
||||
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||
}
|
||||
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg *Config) EnsureDirectories() error {
|
||||
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (cfg *Config) captureDefaults() {
|
||||
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
||||
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
||||
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
|
||||
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
||||
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
||||
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
||||
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
||||
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
||||
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerEditEnabled, formatBool(cfg.BoxOwnerEditEnabled), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerRefreshEnabled, formatBool(cfg.BoxOwnerRefreshEnabled), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxRefreshCount, strconv.Itoa(cfg.BoxOwnerMaxRefreshCount), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxRefreshAmount, strconv.FormatInt(cfg.BoxOwnerMaxRefreshAmountSeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxTotalExpiry, strconv.FormatInt(cfg.BoxOwnerMaxTotalExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerPasswordEdit, formatBool(cfg.BoxOwnerPasswordEditEnabled), 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
|
||||
}
|
||||
112
lib/config/models.go
Normal file
112
lib/config/models.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceDefault Source = "default"
|
||||
SourceEnv Source = "environment"
|
||||
SourceDB Source = "db override"
|
||||
)
|
||||
|
||||
type AdminEnabledMode string
|
||||
|
||||
const (
|
||||
AdminEnabledAuto AdminEnabledMode = "auto"
|
||||
AdminEnabledTrue AdminEnabledMode = "true"
|
||||
AdminEnabledFalse AdminEnabledMode = "false"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
||||
SettingAPIEnabled = "api_enabled"
|
||||
SettingZipDownloadsEnabled = "zip_downloads_enabled"
|
||||
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
|
||||
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
|
||||
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
|
||||
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
|
||||
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
||||
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
||||
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
||||
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
||||
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||
SettingDataDir = "data_dir"
|
||||
SettingBoxOwnerEditEnabled = "box_owner_edit_enabled"
|
||||
SettingBoxOwnerRefreshEnabled = "box_owner_refresh_enabled"
|
||||
SettingBoxOwnerMaxRefreshCount = "box_owner_max_refresh_count"
|
||||
SettingBoxOwnerMaxRefreshAmount = "box_owner_max_refresh_amount_seconds"
|
||||
SettingBoxOwnerMaxTotalExpiry = "box_owner_max_total_expiry_seconds"
|
||||
SettingBoxOwnerPasswordEdit = "box_owner_password_edit_enabled"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
SettingTypeBool SettingType = "bool"
|
||||
SettingTypeInt64 SettingType = "int64"
|
||||
SettingTypeInt SettingType = "int"
|
||||
SettingTypeText SettingType = "text"
|
||||
)
|
||||
|
||||
type SettingDefinition struct {
|
||||
Key string
|
||||
EnvName string
|
||||
Label string
|
||||
Type SettingType
|
||||
Editable bool
|
||||
HardLimit bool
|
||||
Minimum int64
|
||||
}
|
||||
|
||||
type SettingRow struct {
|
||||
Definition SettingDefinition
|
||||
Value string
|
||||
Source Source
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DataDir string
|
||||
UploadsDir string
|
||||
DBDir string
|
||||
|
||||
AdminPassword string
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminEnabled AdminEnabledMode
|
||||
AdminCookieSecure bool
|
||||
AllowAdminSettingsOverride bool
|
||||
|
||||
GuestUploadsEnabled bool
|
||||
APIEnabled bool
|
||||
ZipDownloadsEnabled bool
|
||||
OneTimeDownloadsEnabled bool
|
||||
OneTimeDownloadExpirySeconds int64
|
||||
OneTimeDownloadRetryOnFailure bool
|
||||
RenewOnAccessEnabled bool
|
||||
RenewOnDownloadEnabled bool
|
||||
|
||||
DefaultGuestExpirySeconds int64
|
||||
MaxGuestExpirySeconds int64
|
||||
GlobalMaxFileSizeBytes int64
|
||||
GlobalMaxBoxSizeBytes int64
|
||||
DefaultUserMaxFileSizeBytes int64
|
||||
DefaultUserMaxBoxSizeBytes int64
|
||||
SessionTTLSeconds int64
|
||||
BoxPollIntervalMS int
|
||||
ThumbnailBatchSize int
|
||||
ThumbnailIntervalSeconds int
|
||||
BoxOwnerEditEnabled bool
|
||||
BoxOwnerRefreshEnabled bool
|
||||
BoxOwnerMaxRefreshCount int
|
||||
BoxOwnerMaxRefreshAmountSeconds int64
|
||||
BoxOwnerMaxTotalExpirySeconds int64
|
||||
BoxOwnerPasswordEditEnabled bool
|
||||
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
}
|
||||
127
lib/config/overrides.go
Normal file
127
lib/config/overrides.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||
if !cfg.AllowAdminSettingsOverride {
|
||||
return nil
|
||||
}
|
||||
for key, value := range overrides {
|
||||
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||
def, ok := Definition(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown setting %q", key)
|
||||
}
|
||||
if !def.Editable || def.HardLimit {
|
||||
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||
}
|
||||
|
||||
switch def.Type {
|
||||
case SettingTypeBool:
|
||||
parsed, err := parseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
cfg.assignBool(key, parsed, SourceDB)
|
||||
case SettingTypeInt64:
|
||||
parsed, err := parseInt64(value, def.Minimum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
cfg.assignInt64(key, parsed, SourceDB)
|
||||
case SettingTypeInt:
|
||||
parsed64, err := parseInt64(value, def.Minimum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||
default:
|
||||
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||
switch key {
|
||||
case SettingGuestUploadsEnabled:
|
||||
cfg.GuestUploadsEnabled = value
|
||||
case SettingAPIEnabled:
|
||||
cfg.APIEnabled = value
|
||||
case SettingZipDownloadsEnabled:
|
||||
cfg.ZipDownloadsEnabled = value
|
||||
case SettingOneTimeDownloadsEnabled:
|
||||
cfg.OneTimeDownloadsEnabled = value
|
||||
case SettingRenewOnAccessEnabled:
|
||||
cfg.RenewOnAccessEnabled = value
|
||||
case SettingRenewOnDownloadEnabled:
|
||||
cfg.RenewOnDownloadEnabled = value
|
||||
case SettingBoxOwnerEditEnabled:
|
||||
cfg.BoxOwnerEditEnabled = value
|
||||
case SettingBoxOwnerRefreshEnabled:
|
||||
cfg.BoxOwnerRefreshEnabled = value
|
||||
case SettingBoxOwnerPasswordEdit:
|
||||
cfg.BoxOwnerPasswordEditEnabled = value
|
||||
}
|
||||
cfg.setValue(key, formatBool(value), source)
|
||||
}
|
||||
|
||||
func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
||||
switch key {
|
||||
case SettingDefaultGuestExpirySecs:
|
||||
cfg.DefaultGuestExpirySeconds = value
|
||||
case SettingMaxGuestExpirySecs:
|
||||
cfg.MaxGuestExpirySeconds = value
|
||||
case SettingOneTimeDownloadExpirySecs:
|
||||
cfg.OneTimeDownloadExpirySeconds = value
|
||||
case SettingDefaultUserMaxFileBytes:
|
||||
cfg.DefaultUserMaxFileSizeBytes = value
|
||||
case SettingDefaultUserMaxBoxBytes:
|
||||
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||
case SettingSessionTTLSeconds:
|
||||
cfg.SessionTTLSeconds = value
|
||||
case SettingBoxOwnerMaxRefreshAmount:
|
||||
cfg.BoxOwnerMaxRefreshAmountSeconds = value
|
||||
case SettingBoxOwnerMaxTotalExpiry:
|
||||
cfg.BoxOwnerMaxTotalExpirySeconds = 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
|
||||
case SettingBoxOwnerMaxRefreshCount:
|
||||
cfg.BoxOwnerMaxRefreshCount = value
|
||||
}
|
||||
cfg.setValue(key, strconv.Itoa(value), source)
|
||||
}
|
||||
|
||||
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
cfg.values[key] = value
|
||||
cfg.sources[key] = source
|
||||
}
|
||||
|
||||
func (cfg *Config) sourceFor(key string) Source {
|
||||
source, ok := cfg.sources[key]
|
||||
if !ok {
|
||||
return SourceDefault
|
||||
}
|
||||
return source
|
||||
}
|
||||
47
lib/config/parse.go
Normal file
47
lib/config/parse.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseBool(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "t", "true", "y", "yes", "on":
|
||||
return true, nil
|
||||
case "0", "f", "false", "n", "no", "off":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("must be a boolean")
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64(value string, min int64) (int64, error) {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
if parsed < min {
|
||||
return 0, fmt.Errorf("must be at least %d", min)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseInt(value string, min int) (int, error) {
|
||||
parsed64, err := parseInt64(value, int64(min))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if parsed64 > int64(^uint(0)>>1) {
|
||||
return 0, fmt.Errorf("is too large")
|
||||
}
|
||||
return int(parsed64), nil
|
||||
}
|
||||
|
||||
func formatBool(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
20
lib/helpers/env.go
Normal file
20
lib/helpers/env.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func EnvInt(name string, fallback int, minimum int) int {
|
||||
rawValue := os.Getenv(name)
|
||||
if rawValue == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(rawValue)
|
||||
if err != nil || value < minimum {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
20
lib/helpers/format.go
Normal file
20
lib/helpers/format.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package helpers
|
||||
|
||||
import "fmt"
|
||||
|
||||
func FormatBytes(bytes int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB"}
|
||||
size := float64(bytes)
|
||||
unitIndex := 0
|
||||
|
||||
for size >= 1024 && unitIndex < len(units)-1 {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
if unitIndex == 0 {
|
||||
return fmt.Sprintf("%d %s", bytes, units[unitIndex])
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
30
lib/helpers/ids.go
Normal file
30
lib/helpers/ids.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RandomHexID(byteCount int) (string, error) {
|
||||
bytes := make([]byte, byteCount)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func ValidLowerHexID(value string, length int) bool {
|
||||
if len(value) != length {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, character := range value {
|
||||
if !strings.ContainsRune("0123456789abcdef", character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
30
lib/helpers/mime.go
Normal file
30
lib/helpers/mime.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func MimeTypeForFile(path string, filename string) string {
|
||||
if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buffer := make([]byte, 512)
|
||||
bytesRead, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
return http.DetectContentType(buffer[:bytesRead])
|
||||
}
|
||||
58
lib/helpers/paths.go
Normal file
58
lib/helpers/paths.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafeFilename(name string) (string, bool) {
|
||||
filename := filepath.Base(name)
|
||||
filename = strings.TrimSpace(filename)
|
||||
return filename, filename != "" && filename != "." && filename != string(filepath.Separator)
|
||||
}
|
||||
|
||||
func SafeChildPath(parent string, filename string) (string, bool) {
|
||||
parent = filepath.Clean(parent)
|
||||
filename = strings.TrimSpace(filename)
|
||||
if parent == "" || filename == "" || filepath.IsAbs(filename) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
path := filepath.Clean(filepath.Join(parent, filename))
|
||||
relative, err := filepath.Rel(parent, path)
|
||||
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return path, true
|
||||
}
|
||||
|
||||
func UniqueFilename(directory string, filename string) string {
|
||||
if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) {
|
||||
return filename
|
||||
}
|
||||
|
||||
extension := filepath.Ext(filename)
|
||||
base := strings.TrimSuffix(filename, extension)
|
||||
for count := 2; ; count++ {
|
||||
candidate := base + "-" + strconv.Itoa(count) + extension
|
||||
if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UniqueNameInBatch(filename string, usedNames map[string]int) string {
|
||||
count := usedNames[filename]
|
||||
usedNames[filename] = count + 1
|
||||
|
||||
if count == 0 {
|
||||
return filename
|
||||
}
|
||||
|
||||
extension := filepath.Ext(filename)
|
||||
base := strings.TrimSuffix(filename, extension)
|
||||
return base + "-" + strconv.Itoa(count+1) + extension
|
||||
}
|
||||
20
lib/helpers/paths_test.go
Normal file
20
lib/helpers/paths_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeChildPathRejectsTraversalAndAbsolutePaths(t *testing.T) {
|
||||
parent := filepath.Join(t.TempDir(), "parent")
|
||||
|
||||
if _, ok := SafeChildPath(parent, "../outside.txt"); ok {
|
||||
t.Fatal("expected traversal to be rejected")
|
||||
}
|
||||
if _, ok := SafeChildPath(parent, filepath.Join(string(filepath.Separator), "tmp", "outside.txt")); ok {
|
||||
t.Fatal("expected absolute path to be rejected")
|
||||
}
|
||||
if path, ok := SafeChildPath(parent, "inside.txt"); !ok || path != filepath.Join(parent, "inside.txt") {
|
||||
t.Fatalf("expected safe child path, got path=%q ok=%v", path, ok)
|
||||
}
|
||||
}
|
||||
247
lib/metastore/alerts.go
Normal file
247
lib/metastore/alerts.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertSeverityLow = "low"
|
||||
AlertSeverityMedium = "medium"
|
||||
AlertSeverityHigh = "high"
|
||||
|
||||
AlertStatusOpen = "open"
|
||||
AlertStatusAcknowledged = "acknowledged"
|
||||
AlertStatusClosed = "closed"
|
||||
)
|
||||
|
||||
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
|
||||
alert, err := normalizeAlertInput(input)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
id, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
alert.ID = id
|
||||
alert.Status = AlertStatusOpen
|
||||
alert.CreatedAt = now
|
||||
alert.UpdatedAt = now
|
||||
|
||||
err = store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, alertKey(alert.ID), alert)
|
||||
})
|
||||
return alert, err
|
||||
}
|
||||
|
||||
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
|
||||
alerts := []Alert{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("alert/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var alert Alert
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &alert)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if alertMatchesFilters(alert, filters) {
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortAlerts(alerts, filters.Sort)
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetAlert(id string) (Alert, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
var alert Alert
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, alertKey(id), &alert)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
return alert, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) AcknowledgeAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusAcknowledged)
|
||||
}
|
||||
|
||||
func (store *Store) CloseAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusClosed)
|
||||
}
|
||||
|
||||
func (store *Store) updateAlertStatus(id string, status string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
|
||||
}
|
||||
status, err := normalizeAlertStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
var alert Alert
|
||||
if err := getJSON(txn, alertKey(id), &alert); err != nil {
|
||||
return err
|
||||
}
|
||||
alert.Status = status
|
||||
alert.UpdatedAt = now
|
||||
switch status {
|
||||
case AlertStatusAcknowledged:
|
||||
alert.AcknowledgedAt = &now
|
||||
case AlertStatusClosed:
|
||||
alert.ClosedAt = &now
|
||||
}
|
||||
return putJSON(txn, alertKey(id), alert)
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAlertInput(input AlertInput) (Alert, error) {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
description := strings.TrimSpace(input.Description)
|
||||
code := strings.TrimSpace(input.Code)
|
||||
trace := strings.TrimSpace(input.Trace)
|
||||
severity, err := normalizeAlertSeverity(input.Severity)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
if title == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
|
||||
}
|
||||
if code == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
|
||||
}
|
||||
if trace == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
|
||||
}
|
||||
metadata := input.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(metadata, &object); err != nil {
|
||||
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
|
||||
}
|
||||
normalizedMetadata, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
return Alert{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: normalizedMetadata,
|
||||
CreatedBy: strings.TrimSpace(input.CreatedBy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAlertSeverity(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAlertStatus(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
|
||||
return false
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
|
||||
return false
|
||||
}
|
||||
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortAlerts(alerts []Alert, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
|
||||
case "severity":
|
||||
sort.Slice(alerts, func(i int, j int) bool {
|
||||
left := alertSeverityRank(alerts[i].Severity)
|
||||
right := alertSeverityRank(alerts[j].Severity)
|
||||
if left == right {
|
||||
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
default:
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func alertSeverityRank(severity string) int {
|
||||
switch severity {
|
||||
case AlertSeverityHigh:
|
||||
return 3
|
||||
case AlertSeverityMedium:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func alertGroup(trace string) string {
|
||||
trace = strings.TrimSpace(trace)
|
||||
if trace == "" {
|
||||
return "system"
|
||||
}
|
||||
before, _, found := strings.Cut(trace, ".")
|
||||
if !found || before == "" {
|
||||
return "system"
|
||||
}
|
||||
return strings.ToLower(before)
|
||||
}
|
||||
|
||||
func alertKey(id string) []byte {
|
||||
return []byte("alert/" + strings.TrimSpace(id))
|
||||
}
|
||||
89
lib/metastore/alerts_test.go
Normal file
89
lib/metastore/alerts_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertCreateListFilterLifecycle(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
alert, err := store.CreateAlert(AlertInput{
|
||||
Title: "Thumbnail failed",
|
||||
Description: "Could not generate preview.",
|
||||
Severity: AlertSeverityMedium,
|
||||
Code: "601",
|
||||
Trace: "thumbnail.generate.failed",
|
||||
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert returned error: %v", err)
|
||||
}
|
||||
if alert.ID == "" || alert.Status != AlertStatusOpen {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
|
||||
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAlerts returned error: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
|
||||
t.Fatalf("unexpected filtered alerts: %#v", alerts)
|
||||
}
|
||||
|
||||
if !json.Valid(alerts[0].Metadata) {
|
||||
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
|
||||
}
|
||||
var metadata map[string]string
|
||||
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
|
||||
t.Fatalf("Unmarshal metadata returned error: %v", err)
|
||||
}
|
||||
if metadata["file"] != "photo.jpg" {
|
||||
t.Fatalf("metadata did not survive round trip: %#v", metadata)
|
||||
}
|
||||
|
||||
if err := store.AcknowledgeAlert(alert.ID); err != nil {
|
||||
t.Fatalf("AcknowledgeAlert returned error: %v", err)
|
||||
}
|
||||
acknowledged, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
|
||||
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
|
||||
}
|
||||
|
||||
if err := store.CloseAlert(alert.ID); err != nil {
|
||||
t.Fatalf("CloseAlert returned error: %v", err)
|
||||
}
|
||||
closed, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
|
||||
t.Fatalf("expected closed alert, got %#v", closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRejectsInvalidMetadata(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.CreateAlert(AlertInput{
|
||||
Title: "Bad alert",
|
||||
Severity: AlertSeverityLow,
|
||||
Code: "999",
|
||||
Trace: "test.bad",
|
||||
Metadata: json.RawMessage(`[]`),
|
||||
}); err == nil {
|
||||
t.Fatal("expected non-object metadata to be rejected")
|
||||
}
|
||||
}
|
||||
71
lib/metastore/bootstrap.go
Normal file
71
lib/metastore/bootstrap.go
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
188
lib/metastore/boxes.go
Normal file
188
lib/metastore/boxes.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
|
||||
record.ID = strings.TrimSpace(record.ID)
|
||||
if record.ID == "" {
|
||||
return errors.New("box id cannot be empty")
|
||||
}
|
||||
record.OwnerID = strings.TrimSpace(record.OwnerID)
|
||||
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
|
||||
record.FileNames = uniqueStrings(record.FileNames)
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, boxRecordKey(record.ID), record)
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
|
||||
var record BoxRecord
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, boxRecordKey(id), &record)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return BoxRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) DeleteBoxRecord(id string) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
err := txn.Delete(boxRecordKey(id))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
|
||||
if page.Page < 1 {
|
||||
page.Page = 1
|
||||
}
|
||||
switch page.PageSize {
|
||||
case 25, 50, 100:
|
||||
default:
|
||||
page.PageSize = 25
|
||||
}
|
||||
|
||||
rows := []BoxRecord{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("box_record/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var record BoxRecord
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &record)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if boxRecordMatches(record, filters) {
|
||||
rows = append(rows, record)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BoxRecordPage{}, err
|
||||
}
|
||||
|
||||
sortBoxRecords(rows, filters.Sort)
|
||||
total := len(rows)
|
||||
start := (page.Page - 1) * page.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + page.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + page.PageSize - 1) / page.PageSize
|
||||
}
|
||||
return BoxRecordPage{
|
||||
Rows: rows[start:end],
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: total,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: end < total,
|
||||
PrevPage: maxInt(page.Page-1, 1),
|
||||
NextPage: page.Page + 1,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
|
||||
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
|
||||
return false
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(filters.Status))
|
||||
if status != "" && status != "all" && boxRecordStatus(record) != status {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
|
||||
case "", "all":
|
||||
return true
|
||||
case "password":
|
||||
return record.PasswordProtected
|
||||
case "one-time":
|
||||
return record.OneTimeDownload
|
||||
case "zip-disabled":
|
||||
return record.DisableZip
|
||||
case "expired":
|
||||
return boxRecordExpired(record)
|
||||
case "refreshable":
|
||||
return !record.OneTimeDownload && !boxRecordExpired(record)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sortBoxRecords(rows []BoxRecord, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
|
||||
case "largest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
|
||||
case "expires":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
|
||||
case "expired":
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
left := boxRecordExpired(rows[i])
|
||||
right := boxRecordExpired(rows[j])
|
||||
if left == right {
|
||||
return rows[i].CreatedAt.After(rows[j].CreatedAt)
|
||||
}
|
||||
return left
|
||||
})
|
||||
default:
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func boxRecordStatus(record BoxRecord) string {
|
||||
if boxRecordExpired(record) {
|
||||
return "expired"
|
||||
}
|
||||
if record.ExpiresAt.IsZero() {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func boxRecordExpired(record BoxRecord) bool {
|
||||
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||
}
|
||||
|
||||
func boxRecordKey(id string) []byte {
|
||||
return []byte("box_record/" + strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
222
lib/metastore/metastore_test.go
Normal file
222
lib/metastore/metastore_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
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, "")
|
||||
}
|
||||
}
|
||||
242
lib/metastore/models.go
Normal file
242
lib/metastore/models.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"`
|
||||
AdminNote string `json:"admin_note,omitempty"`
|
||||
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
|
||||
}
|
||||
|
||||
type UserPermOverrides struct {
|
||||
UploadAllowed *bool `json:"upload_allowed,omitempty"`
|
||||
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
|
||||
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
|
||||
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
|
||||
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
|
||||
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
|
||||
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
|
||||
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
|
||||
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,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"`
|
||||
AdminUsersView bool `json:"admin_users_view"`
|
||||
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
|
||||
AdminUsersView bool
|
||||
AdminUsersManage bool
|
||||
AdminSettingsManage bool
|
||||
AdminBoxesView bool
|
||||
}
|
||||
|
||||
type BootstrapResult struct {
|
||||
AdminTag Tag
|
||||
AdminUser *User
|
||||
AdminLoginEnabled bool
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Trace string `json:"trace"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
type AlertInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Code string
|
||||
Trace string
|
||||
Metadata json.RawMessage
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type AlertFilters struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxRecord struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
FileNames []string `json:"file_names,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
DisableZip bool `json:"disable_zip"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BoxFilters struct {
|
||||
Query string
|
||||
Owner string
|
||||
Status string
|
||||
Flag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BoxRecordPage struct {
|
||||
Rows []BoxRecord
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
type UserFilters struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type UserPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type UserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Status string
|
||||
Role string
|
||||
TagIDs []string
|
||||
Tags string
|
||||
Plan string
|
||||
PolicySummary string
|
||||
BoxCount int
|
||||
APIKeyCount int
|
||||
CreatedAt string
|
||||
LastSeen string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
IsInvite bool
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Rows []UserRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
Stats UserPageStats
|
||||
}
|
||||
|
||||
type UserPageStats struct {
|
||||
TotalUsers int
|
||||
ActiveUsers int
|
||||
PendingInvites int
|
||||
DisabledUsers int
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
Email string
|
||||
Password string
|
||||
Mode string
|
||||
Role string
|
||||
Plan string
|
||||
AdminNote string
|
||||
SendSetup bool
|
||||
ForceChange bool
|
||||
}
|
||||
|
||||
type CreateUserResult struct {
|
||||
User User
|
||||
InviteToken string
|
||||
InviteLink string
|
||||
IsInvite bool
|
||||
PasswordSet string
|
||||
InviteNotSent bool
|
||||
}
|
||||
157
lib/metastore/permissions.go
Normal file
157
lib/metastore/permissions.go
Normal file
@@ -0,0 +1,157 @@
|
||||
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.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
|
||||
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
|
||||
}
|
||||
|
||||
if o := user.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
perms.UploadAllowed = *o.UploadAllowed
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
perms.RenewableAllowed = *o.RenewableAllowed
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
79
lib/metastore/sessions.go
Normal file
79
lib/metastore/sessions.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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))
|
||||
}
|
||||
669
lib/metastore/store.go
Normal file
669
lib/metastore/store.go
Normal file
@@ -0,0 +1,669 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"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) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
|
||||
tags, err := store.ListTags()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
tagMap := make(map[string]Tag, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagMap[tag.ID] = tag
|
||||
}
|
||||
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
filtered := make([]User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(user.Username), query) &&
|
||||
!strings.Contains(strings.ToLower(user.Email), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch filters.Status {
|
||||
case "active":
|
||||
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "disabled":
|
||||
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "pending":
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filters.Role != "" && filters.Role != "all" {
|
||||
match := false
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
|
||||
switch filters.Sort {
|
||||
case "createdDesc":
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
case "username":
|
||||
fallthrough
|
||||
default:
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
|
||||
})
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
pageSize := pageReq.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 12
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
page := pageReq.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageUsers := filtered[start:end]
|
||||
|
||||
stats := UserPageStats{TotalUsers: len(users)}
|
||||
for _, user := range users {
|
||||
if strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
stats.PendingInvites++
|
||||
} else if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]UserRow, len(pageUsers))
|
||||
for i, user := range pageUsers {
|
||||
role := ""
|
||||
tagNames := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if tag.Permissions.AdminAccess && role == "" {
|
||||
role = tag.Name
|
||||
} else if role == "" {
|
||||
role = tag.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
plan := "standard"
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
|
||||
plan = "unlimited"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
|
||||
status := userStatus(user.Disabled)
|
||||
if isInvite {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
rows[i] = UserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Status: status,
|
||||
Role: role,
|
||||
TagIDs: user.TagIDs,
|
||||
Tags: strings.Join(tagNames, ", "),
|
||||
Plan: plan,
|
||||
PolicySummary: "system default",
|
||||
BoxCount: 0,
|
||||
APIKeyCount: 0,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
LastSeen: "-",
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: false,
|
||||
IsInvite: isInvite,
|
||||
}
|
||||
}
|
||||
|
||||
return UserPage{
|
||||
Rows: rows,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
TotalPages: totalPages,
|
||||
Stats: stats,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userStatus(disabled bool) string {
|
||||
if disabled {
|
||||
return "disabled"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, id := range ids {
|
||||
var user User
|
||||
if err := getJSON(txn, userKey(id), &user); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
user.Disabled = disabled
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
if err := putJSON(txn, userKey(id), user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) RevokeUserSessions(userID string) error {
|
||||
tokens, err := store.sessionTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, token := range tokens {
|
||||
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) BulkRevokeUserSessions(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := store.RevokeUserSessions(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
|
||||
tokens := []string{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("session/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var session Session
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &session)
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
if session.UserID == userID {
|
||||
tokens = append(tokens, session.Token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTagID {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
|
||||
hash, err := helpers.RandomHexID(32)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: "invite/" + hash,
|
||||
TagIDs: uniqueStrings(tagIDs),
|
||||
Disabled: true,
|
||||
}
|
||||
if err := store.CreateUser(&user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
221
lib/metastore/tags.go
Normal file
221
lib/metastore/tags.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
AdminUsersView: 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))
|
||||
}
|
||||
94
lib/models/models.go
Normal file
94
lib/models/models.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
FileStatusFailed = "failed"
|
||||
FileStatusReady = "complete"
|
||||
FileStatusWait = "pending"
|
||||
FileStatusWork = "uploading"
|
||||
)
|
||||
|
||||
const (
|
||||
ThumbnailStatusFailed = "failed"
|
||||
ThumbnailStatusProcessing = "processing"
|
||||
ThumbnailStatusReady = "ready"
|
||||
ThumbnailStatusUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
type RetentionOption struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Seconds int64 `json:"seconds"`
|
||||
}
|
||||
|
||||
type BoxFile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SizeLabel string `json:"size_label"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"status_label"`
|
||||
Title string `json:"title"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ThumbnailPath *string `json:"thumbnail_path"`
|
||||
ThumbnailStatus string `json:"thumbnail_status,omitempty"`
|
||||
ThumbnailURL string `json:"-"`
|
||||
DownloadPath string `json:"download_path"`
|
||||
UploadPath string `json:"upload_path"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
type BoxManifest struct {
|
||||
Files []BoxFile `json:"files"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
Activity []BoxActivity `json:"activity,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
RetentionSecs int64 `json:"retention_seconds"`
|
||||
PasswordSalt string `json:"password_salt,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||
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 BoxActivity struct {
|
||||
At time.Time `json:"at"`
|
||||
Message string `json:"message"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
type BoxSummary struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSize int64
|
||||
TotalSizeLabel string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Expired bool
|
||||
OneTimeDownload bool
|
||||
PasswordProtected bool
|
||||
}
|
||||
|
||||
type CreateBoxRequest struct {
|
||||
Files []CreateBoxFileRequest `json:"files"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
Password string `json:"password"`
|
||||
AllowZip *bool `json:"allow_zip"`
|
||||
}
|
||||
|
||||
type CreateBoxFileRequest struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type UpdateFileStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
39
lib/routing/routes.go
Normal file
39
lib/routing/routes.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package routing
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type Handlers struct {
|
||||
Index gin.HandlerFunc
|
||||
ShowBox gin.HandlerFunc
|
||||
BoxLogin gin.HandlerFunc
|
||||
BoxLoginPost gin.HandlerFunc
|
||||
BoxStatus gin.HandlerFunc
|
||||
DownloadBox gin.HandlerFunc
|
||||
DownloadFile gin.HandlerFunc
|
||||
DownloadThumbnail gin.HandlerFunc
|
||||
CreateBox gin.HandlerFunc
|
||||
ManifestFileUpload gin.HandlerFunc
|
||||
FileStatusUpdate gin.HandlerFunc
|
||||
DirectBoxUpload gin.HandlerFunc
|
||||
LegacyUpload gin.HandlerFunc
|
||||
}
|
||||
|
||||
func Register(router *gin.Engine, handlers Handlers) {
|
||||
router.GET("/", handlers.Index)
|
||||
|
||||
router.GET("/box/:id", handlers.ShowBox)
|
||||
router.GET("/box/:id/login", handlers.BoxLogin)
|
||||
router.GET("/box/:id/status", handlers.BoxStatus)
|
||||
router.GET("/box/:id/download", handlers.DownloadBox)
|
||||
router.GET("/box/:id/files/:filename", handlers.DownloadFile)
|
||||
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
|
||||
|
||||
router.POST("/box", handlers.CreateBox)
|
||||
router.POST("/box/:id/login", handlers.BoxLoginPost)
|
||||
router.POST("/box/:id/files/:file_id/upload", handlers.ManifestFileUpload)
|
||||
router.POST("/box/:id/files/:file_id/status", handlers.FileStatusUpdate)
|
||||
|
||||
// Legacy upload routes are kept for compatibility with older clients.
|
||||
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
||||
router.POST("/upload", handlers.LegacyUpload)
|
||||
}
|
||||
386
lib/server/account_alerts.go
Normal file
386
lib/server/account_alerts.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AlertPageView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters AlertFiltersView
|
||||
Stats AlertStatsView
|
||||
Alerts []AlertRowView
|
||||
SelectedAlert *AlertRowView
|
||||
Groups []string
|
||||
CanManageAlerts bool
|
||||
}
|
||||
|
||||
type AlertFiltersView struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type AlertStatsView struct {
|
||||
Open int
|
||||
Acknowledged int
|
||||
Closed int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
type AlertRowView struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Status string
|
||||
Code string
|
||||
Trace string
|
||||
Group string
|
||||
MetadataPretty string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlerts(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_alerts.html", page)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.AcknowledgeAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.CloseAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkCloseAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
|
||||
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.Param("id")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return metastore.Alert{}, err
|
||||
}
|
||||
if input.CreatedBy == "" {
|
||||
input.CreatedBy = actor.Username
|
||||
}
|
||||
return app.store.CreateAlert(input)
|
||||
}
|
||||
|
||||
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
|
||||
if err := app.requireAlertView(ctx); err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
alerts, err := app.store.ListAlerts(filters)
|
||||
if err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
rows := make([]AlertRowView, 0, len(alerts))
|
||||
stats := AlertStatsView{}
|
||||
groupSet := map[string]bool{}
|
||||
for _, alert := range alerts {
|
||||
row := alertRowView(alert)
|
||||
rows = append(rows, row)
|
||||
groupSet[row.Group] = true
|
||||
switch alert.Status {
|
||||
case metastore.AlertStatusAcknowledged:
|
||||
stats.Acknowledged++
|
||||
case metastore.AlertStatusClosed:
|
||||
stats.Closed++
|
||||
default:
|
||||
stats.Open++
|
||||
}
|
||||
switch alert.Severity {
|
||||
case metastore.AlertSeverityHigh:
|
||||
stats.High++
|
||||
case metastore.AlertSeverityMedium:
|
||||
stats.Medium++
|
||||
default:
|
||||
stats.Low++
|
||||
}
|
||||
}
|
||||
groups := make([]string, 0, len(groupSet))
|
||||
for group := range groupSet {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
groups = []string{"system"}
|
||||
}
|
||||
|
||||
nav := app.accountNavView(ctx, "alerts")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
|
||||
var selected *AlertRowView
|
||||
if len(rows) > 0 {
|
||||
selected = &rows[0]
|
||||
}
|
||||
return AlertPageView{
|
||||
PageTitle: "WarpBox Alerts",
|
||||
WindowTitle: "WarpBox Alerts",
|
||||
WindowIcon: "!",
|
||||
PageScripts: []string{"/static/js/account-alerts.js"},
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
|
||||
Stats: stats,
|
||||
Alerts: rows,
|
||||
SelectedAlert: selected,
|
||||
Groups: groups,
|
||||
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.AcknowledgeAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.CloseAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.AcknowledgeAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.CloseAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
log.Printf("alert metadata marshal failed: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = app.store.CreateAlert(metastore.AlertInput{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: raw,
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("alert persistence failed: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *App) requireAlertView(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) requireAlertManage(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
|
||||
return metastore.AlertFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Severity: emptyAsAll(ctx.Query("severity")),
|
||||
Status: emptyAsAll(ctx.Query("status")),
|
||||
Group: emptyAsAll(ctx.Query("group")),
|
||||
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func emptyAsAll(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "all"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func emptyAsNewest(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "newest"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func alertRowView(alert metastore.Alert) AlertRowView {
|
||||
return AlertRowView{
|
||||
ID: alert.ID,
|
||||
Title: alert.Title,
|
||||
Description: alert.Description,
|
||||
Severity: alert.Severity,
|
||||
Status: alert.Status,
|
||||
Code: alert.Code,
|
||||
Trace: alert.Trace,
|
||||
Group: alertGroupFromTrace(alert.Trace),
|
||||
MetadataPretty: prettyAlertMetadata(alert.Metadata),
|
||||
CreatedAt: formatAdminTime(alert.CreatedAt),
|
||||
UpdatedAt: formatAdminTime(alert.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func prettyAlertMetadata(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
pretty, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return string(pretty)
|
||||
}
|
||||
|
||||
func alertGroupFromTrace(trace string) string {
|
||||
trace = strings.TrimSpace(trace)
|
||||
if trace == "" {
|
||||
return "system"
|
||||
}
|
||||
before, _, found := strings.Cut(trace, ".")
|
||||
if !found || before == "" {
|
||||
return "system"
|
||||
}
|
||||
return strings.ToLower(before)
|
||||
}
|
||||
|
||||
func (app *App) openAlertSummary() (int, string) {
|
||||
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
|
||||
if err != nil {
|
||||
return 0, "ok"
|
||||
}
|
||||
severity := "ok"
|
||||
for _, alert := range alerts {
|
||||
if alert.Severity == metastore.AlertSeverityHigh {
|
||||
return len(alerts), "danger"
|
||||
}
|
||||
if alert.Severity == metastore.AlertSeverityMedium {
|
||||
severity = "warning"
|
||||
} else if severity == "ok" {
|
||||
severity = "info"
|
||||
}
|
||||
}
|
||||
return len(alerts), severity
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
155
lib/server/account_alerts_test.go
Normal file
155
lib/server/account_alerts_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountAlertsPageListsAndFiltersAlerts(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
createTestAlert(t, app, "701", metastore.AlertSeverityHigh, "storage.connector.health_failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/alerts?severity=high", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected alerts page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "storage.connector.health_failed") {
|
||||
t.Fatal("expected high severity alert")
|
||||
}
|
||||
if strings.Contains(body, "thumbnail.generate.failed") {
|
||||
t.Fatal("did not expect medium severity alert in high filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertAcknowledgeAndClose(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected acknowledge redirect, got %d", response.Code)
|
||||
}
|
||||
updated, ok, err := app.store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if updated.Status != metastore.AlertStatusAcknowledged {
|
||||
t.Fatalf("expected acknowledged alert, got %s", updated.Status)
|
||||
}
|
||||
|
||||
response = postAlertAction(router, session, "/account/alerts/"+alert.ID+"/close", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected close redirect, got %d", response.Code)
|
||||
}
|
||||
updated, ok, err = app.store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if updated.Status != metastore.AlertStatusClosed {
|
||||
t.Fatalf("expected closed alert, got %s", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertManagePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
regular, err := app.store.CreateUserWithPassword("regular-alerts", "regular-alerts@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, regular)
|
||||
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsesRealAlertCount(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "1 alerts") {
|
||||
t.Fatal("expected dashboard alert chip/count")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "Thumbnail alert") {
|
||||
t.Fatal("expected dashboard alert preview")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertsExportJSON(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/alerts/export.json", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected export success, got %d", response.Code)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if _, ok := payload["alerts"]; !ok {
|
||||
t.Fatal("expected alerts export shape")
|
||||
}
|
||||
}
|
||||
|
||||
func createTestAlert(t *testing.T, app *App, code string, severity string, trace string) metastore.Alert {
|
||||
t.Helper()
|
||||
alert, err := app.store.CreateAlert(metastore.AlertInput{
|
||||
Title: "Thumbnail alert",
|
||||
Description: "Alert test description.",
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert returned error: %v", err)
|
||||
}
|
||||
return alert
|
||||
}
|
||||
|
||||
func postAlertAction(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
194
lib/server/account_auth.go
Normal file
194
lib/server/account_auth.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const accountSessionCookie = "warpbox_account_session"
|
||||
|
||||
func (app *App) registerAccountRoutes(router *gin.Engine) {
|
||||
account := router.Group("/account")
|
||||
account.Use(noStoreAdminHeaders)
|
||||
account.GET("/login", app.handleAccountLogin)
|
||||
account.POST("/login", app.handleAccountLoginPost)
|
||||
|
||||
protected := account.Group("")
|
||||
protected.Use(app.requireAccountSession)
|
||||
protected.GET("", app.handleAccountDashboard)
|
||||
protected.GET("/", app.handleAccountDashboard)
|
||||
protected.POST("/logout", app.handleAccountLogout)
|
||||
protected.GET("/settings", app.handleAccountSettings)
|
||||
protected.POST("/settings", app.handleAccountSettingsPost)
|
||||
protected.POST("/settings/reset", app.handleAccountSettingsReset)
|
||||
protected.GET("/settings/export.json", app.handleAccountSettingsExport)
|
||||
protected.POST("/settings/import.json", app.handleAccountSettingsImport)
|
||||
protected.GET("/alerts", app.handleAccountAlerts)
|
||||
protected.GET("/alerts/export.json", app.handleAccountAlertsExport)
|
||||
protected.POST("/alerts/bulk/acknowledge", app.handleAccountAlertBulkAcknowledge)
|
||||
protected.POST("/alerts/bulk/close", app.handleAccountAlertBulkClose)
|
||||
protected.POST("/alerts/:id/acknowledge", app.handleAccountAlertAcknowledge)
|
||||
protected.POST("/alerts/:id/close", app.handleAccountAlertClose)
|
||||
protected.GET("/boxes", app.handleAccountBoxes)
|
||||
protected.GET("/boxes/export.csv", app.handleAccountBoxesExport)
|
||||
protected.POST("/boxes/bulk/expire", app.handleAccountBoxesBulkExpire)
|
||||
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
|
||||
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
|
||||
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
|
||||
protected.GET("/boxes/:id", app.handleAccountBoxManager)
|
||||
protected.POST("/boxes/:id", app.handleAccountBoxUpdate)
|
||||
protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend)
|
||||
protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire)
|
||||
protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete)
|
||||
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
|
||||
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
|
||||
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
|
||||
protected.GET("/users", app.handleAccountUsers)
|
||||
protected.POST("/users", app.handleAccountUsersPost)
|
||||
protected.POST("/users/bulk/disable", app.handleAccountUsersBulkDisable)
|
||||
protected.POST("/users/bulk/enable", app.handleAccountUsersBulkEnable)
|
||||
protected.POST("/users/bulk/revoke-sessions", app.handleAccountUsersBulkRevokeSessions)
|
||||
protected.POST("/users/:id/invite/resend", app.handleAccountUsersResendInvite)
|
||||
protected.GET("/users/:id", app.handleAccountUserEdit)
|
||||
protected.POST("/users/:id", app.handleAccountUserEditPost)
|
||||
protected.POST("/users/:id/disable", app.handleAccountUserDisable)
|
||||
protected.POST("/users/:id/enable", app.handleAccountUserEnable)
|
||||
protected.POST("/users/:id/password/reset", app.handleAccountUserPasswordReset)
|
||||
protected.POST("/users/:id/sessions/revoke", app.handleAccountUserRevokeSessions)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogin(ctx *gin.Context) {
|
||||
if app.isAccountSessionValid(ctx) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
return
|
||||
}
|
||||
app.renderAccountLogin(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled {
|
||||
app.renderAccountLogin(ctx, "Account login is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
user, ok, err := app.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||
return
|
||||
}
|
||||
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||
app.renderAccountLogin(ctx, "The username or password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := app.permissionsForUser(user); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(accountSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/account", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogout(ctx *gin.Context) {
|
||||
if token, err := ctx.Cookie(accountSessionCookie); err == nil {
|
||||
_ = app.store.DeleteSession(token)
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(accountSessionCookie, "", -1, "/account", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
}
|
||||
|
||||
func (app *App) requireAccountSession(ctx *gin.Context) {
|
||||
token, err := ctx.Cookie(accountSessionCookie)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/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, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Set("accountUser", user)
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("accountPerms", perms)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("accountSession", session)
|
||||
ctx.Set("accountCSRFToken", session.CSRFToken)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) isAccountSessionValid(ctx *gin.Context) bool {
|
||||
token, err := ctx.Cookie(accountSessionCookie)
|
||||
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
|
||||
}
|
||||
_, err = app.permissionsForUser(user)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (app *App) renderAccountLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "account_login.html", gin.H{
|
||||
"PageTitle": "WarpBox Account Login",
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
"AccountLoginEnabled": app.adminLoginEnabled,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func currentAccountUser(ctx *gin.Context) (metastore.User, bool) {
|
||||
if current, ok := ctx.Get("accountUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return metastore.User{}, false
|
||||
}
|
||||
515
lib/server/account_box_manager.go
Normal file
515
lib/server/account_box_manager.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
type BoxManagerView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Box BoxManagerSummary
|
||||
Files []BoxManagerFileRow
|
||||
Policy BoxActionPolicy
|
||||
PolicyJSON string
|
||||
Activity []BoxManagerActivityRow
|
||||
Error string
|
||||
}
|
||||
|
||||
type BoxManagerSummary struct {
|
||||
ID string
|
||||
Owner string
|
||||
Status string
|
||||
Storage string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
OpenURL string
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxManagerFileRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
Status string
|
||||
Download string
|
||||
}
|
||||
|
||||
type BoxManagerActivityRow struct {
|
||||
At string
|
||||
Message string
|
||||
Actor string
|
||||
}
|
||||
|
||||
type BoxActionPolicy struct {
|
||||
CanViewManager bool `json:"can_view_manager"`
|
||||
CanEditMetadata bool `json:"can_edit_metadata"`
|
||||
CanEditSharingRules bool `json:"can_edit_sharing_rules"`
|
||||
CanEditPassword bool `json:"can_edit_password"`
|
||||
CanDeleteBox bool `json:"can_delete_box"`
|
||||
CanDeleteFiles bool `json:"can_delete_files"`
|
||||
CanExtendExpiry bool `json:"can_extend_expiry"`
|
||||
MaxExtensionSeconds int64 `json:"max_extension_seconds"`
|
||||
MaxRefreshCount int `json:"max_refresh_count"`
|
||||
MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"`
|
||||
Reasons map[string]string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
type BoxRulesInput struct {
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxPasswordInput struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxManager(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.GetBoxManager(ctx, actor, ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxUpdate(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
input := BoxRulesInput{
|
||||
DisableZip: ctx.PostForm("disable_zip") == "true",
|
||||
OneTimeDownload: ctx.PostForm("one_time_download") == "true",
|
||||
}
|
||||
if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExtend(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||
if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExpire(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPassword(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return BoxManagerView{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
files := make([]BoxManagerFileRow, 0, len(manifest.Files))
|
||||
for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) {
|
||||
files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath})
|
||||
}
|
||||
policyJSON, _ := json.MarshalIndent(policy, "", " ")
|
||||
nav := app.accountNavView(ctx, "boxes")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
return BoxManagerView{
|
||||
PageTitle: "WarpBox Box Manager",
|
||||
WindowTitle: "WarpBox Box Manager",
|
||||
WindowIcon: "B",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Box: BoxManagerSummary{
|
||||
ID: record.ID,
|
||||
Owner: boxOwnerLabel(record),
|
||||
Status: boxStatus(record),
|
||||
Storage: helpers.FormatBytes(record.TotalSize),
|
||||
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||
Flags: boxFlags(record),
|
||||
OpenURL: "/box/" + record.ID,
|
||||
DisableZip: record.DisableZip,
|
||||
OneTimeDownload: record.OneTimeDownload,
|
||||
},
|
||||
Files: files,
|
||||
Policy: policy,
|
||||
PolicyJSON: string(policyJSON),
|
||||
Activity: boxActivityRows(manifest.Activity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditSharingRules {
|
||||
return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled"))
|
||||
}
|
||||
manifest.DisableZip = input.DisableZip
|
||||
manifest.OneTimeDownload = input.OneTimeDownload
|
||||
appendBoxActivity(&manifest, actor.Username, "sharing rules updated")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanExtendExpiry {
|
||||
return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled"))
|
||||
}
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("extension amount must be positive")
|
||||
}
|
||||
if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds {
|
||||
return fmt.Errorf("extension exceeds maximum single extension")
|
||||
}
|
||||
if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount {
|
||||
return fmt.Errorf("refresh count limit reached")
|
||||
}
|
||||
base := manifest.ExpiresAt
|
||||
if base.IsZero() || time.Now().UTC().After(base) {
|
||||
base = time.Now().UTC()
|
||||
}
|
||||
next := base.Add(time.Duration(amount) * time.Second)
|
||||
if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) {
|
||||
return fmt.Errorf("extension exceeds maximum total lifetime")
|
||||
}
|
||||
manifest.ExpiresAt = next
|
||||
record.RefreshCount++
|
||||
appendBoxActivity(&manifest, actor.Username, "expiry extended")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditMetadata {
|
||||
return fmt.Errorf(policyReason(policy, "edit", "edit disabled"))
|
||||
}
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||
appendBoxActivity(&manifest, actor.Username, "box expired")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = manifest
|
||||
if !policy.CanDeleteBox {
|
||||
return fmt.Errorf(policyReason(policy, "delete", "delete disabled"))
|
||||
}
|
||||
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.DeleteBoxRecord(record.ID)
|
||||
}
|
||||
|
||||
func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
password := strings.TrimSpace(input.Password)
|
||||
if password == "" {
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.PasswordHash = string(hash)
|
||||
manifest.PasswordHashAlg = "bcrypt"
|
||||
manifest.AuthToken = token
|
||||
appendBoxActivity(&manifest, actor.Username, "password set")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
manifest.PasswordHash = ""
|
||||
manifest.PasswordHashAlg = ""
|
||||
manifest.PasswordSalt = ""
|
||||
manifest.AuthToken = ""
|
||||
appendBoxActivity(&manifest, actor.Username, "password removed")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanDeleteFiles {
|
||||
return fmt.Errorf(policyReason(policy, "files", "file deletion disabled"))
|
||||
}
|
||||
fileIDs = uniqueNonEmpty(fileIDs)
|
||||
if len(fileIDs) == 0 {
|
||||
return fmt.Errorf("no files selected")
|
||||
}
|
||||
remove := map[string]bool{}
|
||||
for _, id := range fileIDs {
|
||||
remove[id] = true
|
||||
}
|
||||
kept := make([]models.BoxFile, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
if remove[file.ID] {
|
||||
if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, file)
|
||||
}
|
||||
manifest.Files = kept
|
||||
appendBoxActivity(&manifest, actor.Username, "files deleted")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) {
|
||||
view, err := app.GetBoxManager(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, actionErr.Error())
|
||||
return
|
||||
}
|
||||
view.Error = actionErr.Error()
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, BoxActionPolicy{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
return record, manifest, policy, nil
|
||||
}
|
||||
|
||||
func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) {
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id")
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(boxID)
|
||||
if err != nil {
|
||||
return record, models.BoxManifest{}, err
|
||||
}
|
||||
if !ok {
|
||||
return record, models.BoxManifest{}, fmt.Errorf("box not found")
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, err
|
||||
}
|
||||
return record, manifest, nil
|
||||
}
|
||||
|
||||
func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
isAdmin := perms.AdminBoxesView
|
||||
isOwner := record.OwnerID != "" && record.OwnerID == actor.ID
|
||||
policy := BoxActionPolicy{
|
||||
MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds,
|
||||
MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount,
|
||||
MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds,
|
||||
Reasons: map[string]string{},
|
||||
}
|
||||
if isAdmin {
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanEditPassword = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
policy.CanExtendExpiry = !manifest.OneTimeDownload
|
||||
return policy
|
||||
}
|
||||
if !isOwner {
|
||||
policy.Reasons["view"] = "not box owner"
|
||||
return policy
|
||||
}
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
policy.Reasons["view"] = "box owner editing disabled"
|
||||
return policy
|
||||
}
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
if app.config.BoxOwnerPasswordEditEnabled {
|
||||
policy.CanEditPassword = true
|
||||
} else {
|
||||
policy.Reasons["password"] = "password editing disabled by policy"
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
policy.Reasons["extend"] = "refresh disabled by policy"
|
||||
} else if manifest.OneTimeDownload {
|
||||
policy.Reasons["extend"] = "one-time boxes cannot be refreshed"
|
||||
} else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||
policy.Reasons["extend"] = "refresh count limit reached"
|
||||
} else {
|
||||
policy.CanExtendExpiry = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error {
|
||||
if err := boxstore.WriteManifest(record.ID, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
next := boxRecordFromManifest(record.ID, manifest)
|
||||
next.RefreshCount = record.RefreshCount
|
||||
return app.store.UpsertBoxRecord(next)
|
||||
}
|
||||
|
||||
func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) {
|
||||
manifest.Activity = append([]models.BoxActivity{{
|
||||
At: time.Now().UTC(),
|
||||
Actor: actor,
|
||||
Message: message,
|
||||
}}, manifest.Activity...)
|
||||
if len(manifest.Activity) > 12 {
|
||||
manifest.Activity = manifest.Activity[:12]
|
||||
}
|
||||
}
|
||||
|
||||
func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow {
|
||||
rows := make([]BoxManagerActivityRow, 0, len(activity))
|
||||
for _, item := range activity {
|
||||
rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func policyReason(policy BoxActionPolicy, key string, fallback string) string {
|
||||
if policy.Reasons != nil && policy.Reasons[key] != "" {
|
||||
return policy.Reasons[key]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boxOwnerLabel(record metastore.BoxRecord) string {
|
||||
if record.OwnerUsername != "" {
|
||||
return record.OwnerUsername
|
||||
}
|
||||
return "guest"
|
||||
}
|
||||
219
lib/server/account_box_manager_test.go
Normal file
219
lib/server/account_box_manager_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, admin)
|
||||
id := "abababababababababababababababab"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "WarpBox Box Manager") {
|
||||
t.Fatal("expected manager UI")
|
||||
}
|
||||
|
||||
form := url.Values{"disable_zip": []string{"true"}}
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id, form)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected update redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if !manifest.DisableZip {
|
||||
t.Fatal("expected sharing rule update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected owner manager page, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected owner denied by policy, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshCount = 1
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
app.config.BoxOwnerMaxTotalExpirySeconds = 7200
|
||||
user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.RefreshCount != 1 {
|
||||
t.Fatalf("expected refresh count 1, got %d", record.RefreshCount)
|
||||
}
|
||||
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected refresh count rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "refresh count") {
|
||||
t.Fatal("expected refresh count error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "dededededededededededededededede"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected max duration rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "maximum single extension") {
|
||||
t.Fatal("expected max duration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "efefefefefefefefefefefefefefefef"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected password set redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.PasswordHash == "" || manifest.AuthToken == "" {
|
||||
t.Fatal("expected password set")
|
||||
}
|
||||
|
||||
app.config.BoxOwnerPasswordEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected password permission render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "password editing disabled") {
|
||||
t.Fatal("expected password permission error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "fafafafafafafafafafafafafafafafa"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
fileID := manifest.Files[0].ID
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected file delete redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err = boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if len(manifest.Files) != 0 {
|
||||
t.Fatalf("expected file removed, got %#v", manifest.Files)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = true
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected box delete redirect, got %d", response.Code)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
454
lib/server/account_boxes.go
Normal file
454
lib/server/account_boxes.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
type BoxIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters BoxFiltersView
|
||||
Rows []BoxRowView
|
||||
Stats BoxIndexStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
CanManage bool
|
||||
PolicySummary string
|
||||
Error string
|
||||
}
|
||||
|
||||
type BoxFiltersView struct {
|
||||
Query string
|
||||
Owner string
|
||||
Status string
|
||||
Flag string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BoxIndexStats struct {
|
||||
Visible int
|
||||
Total int
|
||||
Expired int
|
||||
Storage string
|
||||
}
|
||||
|
||||
type BoxRowView struct {
|
||||
ID string
|
||||
Owner string
|
||||
Status string
|
||||
FileCount int
|
||||
Size string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
Policy string
|
||||
CanManage bool
|
||||
ManageURL string
|
||||
OpenURL string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxes(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_boxes.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||
return app.BumpBoxExpiries(ctx, actor, ids, seconds)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
filters := boxFiltersFromRequest(ctx)
|
||||
filters.Sort = "largest"
|
||||
page := metastore.BoxPageRequest{Page: 1, PageSize: 25}
|
||||
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, 10)
|
||||
for _, row := range boxPage.Rows {
|
||||
if len(ids) == 10 {
|
||||
break
|
||||
}
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if err := app.DeleteBoxes(ctx, actor, ids); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100})
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"})
|
||||
for _, record := range page.Rows {
|
||||
_ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)})
|
||||
}
|
||||
writer.Flush()
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`)
|
||||
ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes())
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) {
|
||||
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||
if err != nil {
|
||||
return BoxIndexView{}, err
|
||||
}
|
||||
rows := make([]BoxRowView, 0, len(boxPage.Rows))
|
||||
stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total}
|
||||
totalSize := int64(0)
|
||||
for _, record := range boxPage.Rows {
|
||||
totalSize += record.TotalSize
|
||||
if boxExpired(record) {
|
||||
stats.Expired++
|
||||
}
|
||||
rows = append(rows, app.boxRowView(ctx, actor, record))
|
||||
}
|
||||
stats.Storage = helpers.FormatBytes(totalSize)
|
||||
nav := app.accountNavView(ctx, "boxes")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
return BoxIndexView{
|
||||
PageTitle: "WarpBox Boxes",
|
||||
WindowTitle: "WarpBox Boxes",
|
||||
WindowIcon: "B",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize},
|
||||
Rows: rows,
|
||||
Stats: stats,
|
||||
Page: boxPage.Page,
|
||||
PageSize: boxPage.PageSize,
|
||||
Total: boxPage.Total,
|
||||
TotalPages: boxPage.TotalPages,
|
||||
HasPrev: boxPage.HasPrev,
|
||||
HasNext: boxPage.HasNext,
|
||||
PrevURL: boxPageURL(ctx, boxPage.PrevPage),
|
||||
NextURL: boxPageURL(ctx, boxPage.NextPage),
|
||||
CanManage: currentAccountPermissions(ctx).AdminBoxesView,
|
||||
PolicySummary: app.boxPolicySummary(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Add(-time.Second)
|
||||
for _, record := range records {
|
||||
manifest, err := boxstore.ReadManifest(record.ID)
|
||||
if err == nil {
|
||||
manifest.ExpiresAt = now
|
||||
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||
}
|
||||
record.ExpiresAt = now
|
||||
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.store.DeleteBoxRecord(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error {
|
||||
if seconds <= 0 {
|
||||
return fmt.Errorf("bump expiry requires a positive duration")
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return fmt.Errorf("box owner refresh policy is disabled")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds {
|
||||
return fmt.Errorf("bump expiry exceeds maximum refresh amount")
|
||||
}
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.OneTimeDownload {
|
||||
return fmt.Errorf("one-time boxes cannot be refreshed")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||
return fmt.Errorf("box refresh count limit reached")
|
||||
}
|
||||
base := record.ExpiresAt
|
||||
if base.IsZero() || time.Now().UTC().After(base) {
|
||||
base = time.Now().UTC()
|
||||
}
|
||||
newExpiry := base.Add(time.Duration(seconds) * time.Second)
|
||||
if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) {
|
||||
return fmt.Errorf("bump expiry exceeds maximum total expiry")
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(record.ID)
|
||||
if err == nil {
|
||||
manifest.ExpiresAt = newExpiry
|
||||
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||
}
|
||||
record.ExpiresAt = newExpiry
|
||||
record.RefreshCount++
|
||||
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminBoxesView {
|
||||
filters.Owner = actor.ID
|
||||
}
|
||||
return app.store.ListBoxRecords(filters, page)
|
||||
}
|
||||
|
||||
func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) {
|
||||
ids = uniqueNonEmpty(ids)
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no boxes selected")
|
||||
}
|
||||
perms := currentAccountPermissions(ctx)
|
||||
records := make([]metastore.BoxRecord, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("box %s not found", id)
|
||||
}
|
||||
if !perms.AdminBoxesView && record.OwnerID != actor.ID {
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled {
|
||||
return nil, fmt.Errorf("box owner edit policy is disabled")
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView {
|
||||
owner := record.OwnerUsername
|
||||
if owner == "" {
|
||||
owner = "guest"
|
||||
}
|
||||
return BoxRowView{
|
||||
ID: record.ID,
|
||||
Owner: owner,
|
||||
Status: boxStatus(record),
|
||||
FileCount: record.FileCount,
|
||||
Size: helpers.FormatBytes(record.TotalSize),
|
||||
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||
Flags: boxFlags(record),
|
||||
Policy: app.boxRecordPolicy(record),
|
||||
CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID,
|
||||
ManageURL: "/account/boxes/" + record.ID,
|
||||
OpenURL: "/box/" + record.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) indexBoxFromManifest(boxID string) {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest))
|
||||
}
|
||||
|
||||
func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord {
|
||||
total := int64(0)
|
||||
names := make([]string, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
total += file.Size
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return metastore.BoxRecord{
|
||||
ID: boxID,
|
||||
OwnerID: manifest.OwnerID,
|
||||
OwnerUsername: manifest.OwnerUsername,
|
||||
FileNames: names,
|
||||
FileCount: len(manifest.Files),
|
||||
TotalSize: total,
|
||||
CreatedAt: manifest.CreatedAt,
|
||||
ExpiresAt: manifest.ExpiresAt,
|
||||
PasswordProtected: boxstore.IsPasswordProtected(manifest),
|
||||
OneTimeDownload: manifest.OneTimeDownload,
|
||||
DisableZip: manifest.DisableZip,
|
||||
}
|
||||
}
|
||||
|
||||
func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters {
|
||||
return metastore.BoxFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Owner: emptyAsAll(ctx.Query("owner")),
|
||||
Status: emptyAsAll(ctx.Query("status")),
|
||||
Flag: emptyAsAll(ctx.Query("flag")),
|
||||
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest {
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25"))
|
||||
return metastore.BoxPageRequest{Page: page, PageSize: pageSize}
|
||||
}
|
||||
|
||||
func boxStatus(record metastore.BoxRecord) string {
|
||||
if boxExpired(record) {
|
||||
return "expired"
|
||||
}
|
||||
if record.ExpiresAt.IsZero() {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func boxExpired(record metastore.BoxRecord) bool {
|
||||
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||
}
|
||||
|
||||
func boxFlags(record metastore.BoxRecord) string {
|
||||
flags := []string{}
|
||||
if record.PasswordProtected {
|
||||
flags = append(flags, "password")
|
||||
}
|
||||
if record.OneTimeDownload {
|
||||
flags = append(flags, "one-time")
|
||||
}
|
||||
if record.DisableZip {
|
||||
flags = append(flags, "zip disabled")
|
||||
}
|
||||
if boxExpired(record) {
|
||||
flags = append(flags, "expired")
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return "normal"
|
||||
}
|
||||
return strings.Join(flags, ", ")
|
||||
}
|
||||
|
||||
func (app *App) boxRecordPolicy(record metastore.BoxRecord) string {
|
||||
if record.OneTimeDownload {
|
||||
return "one-time: no refresh"
|
||||
}
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
return "owner edits disabled"
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return "editable, no refresh"
|
||||
}
|
||||
return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
|
||||
func (app *App) boxPolicySummary() string {
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
return "Owners cannot edit boxes by default."
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return "Owners can edit boxes but cannot refresh expiry."
|
||||
}
|
||||
return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds))
|
||||
}
|
||||
|
||||
func boxPageURL(ctx *gin.Context, page int) string {
|
||||
query := ctx.Request.URL.Query()
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
return "/account/boxes?" + query.Encode()
|
||||
}
|
||||
|
||||
func parsePositiveInt64Default(raw string, fallback int64) int64 {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
220
lib/server/account_boxes_test.go
Normal file
220
lib/server/account_boxes_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func TestAccountBoxesAdminListsBoxes(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "", 10, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
|
||||
t.Fatal("expected indexed box in admin list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesRegularUserSeesOnlyOwnBoxes(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("box-user", "box-user@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", user.ID, user.Username, 10, false)
|
||||
createIndexedBox(t, app, "cccccccccccccccccccccccccccccccc", "other", "other", 20, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
|
||||
t.Fatal("expected own box")
|
||||
}
|
||||
if strings.Contains(body, "cccccccccccccccccccccccccccccccc") {
|
||||
t.Fatal("did not expect other user's box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesFiltersSortAndPagination(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "11111111111111111111111111111111", "", "", 10, false)
|
||||
createIndexedBox(t, app, "22222222222222222222222222222222", "", "", 1000, true)
|
||||
createIndexedBox(t, app, "33333333333333333333333333333333", "", "", 500, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes?flag=password&sort=largest&page_size=25")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "22222222222222222222222222222222") {
|
||||
t.Fatal("expected password filtered box")
|
||||
}
|
||||
if strings.Contains(body, "11111111111111111111111111111111") {
|
||||
t.Fatal("did not expect unfiltered box")
|
||||
}
|
||||
|
||||
page, err := app.store.ListBoxRecords(metastore.BoxFilters{Sort: "largest"}, metastore.BoxPageRequest{Page: 1, PageSize: 25})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBoxRecords returned error: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 3 || page.Rows[0].ID != "22222222222222222222222222222222" {
|
||||
t.Fatalf("expected largest sort first, got %#v", page.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBulkExpireAndDelete(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "dddddddddddddddddddddddddddddddd"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
values := url.Values{"box_ids": []string{id}}
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/expire", values)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected expire redirect, got %d", response.Code)
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.ExpiresAt.After(time.Now().UTC()) {
|
||||
t.Fatal("expected box to be expired")
|
||||
}
|
||||
|
||||
response = postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", values)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected delete redirect, got %d", response.Code)
|
||||
}
|
||||
if _, ok, err := app.store.GetBoxRecord(id); err != nil || ok {
|
||||
t.Fatalf("expected deleted record, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBulkDeletePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("box-limited", "box-limited@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
createIndexedBox(t, app, id, "other", "other", 10, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", url.Values{"box_ids": []string{id}})
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBumpExpiryPolicyRejection(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerRefreshEnabled = false
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "ffffffffffffffffffffffffffffffff"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/bump-expiry", url.Values{"box_ids": []string{id}, "bump_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected policy rejection, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesDeleteLargest(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
small := "12345123451234512345123451234512"
|
||||
large := "99999999999999999999999999999999"
|
||||
createIndexedBox(t, app, small, "", "", 10, false)
|
||||
createIndexedBox(t, app, large, "", "", 1000, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/delete-largest", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected delete-largest redirect, got %d", response.Code)
|
||||
}
|
||||
if _, ok, err := app.store.GetBoxRecord(large); err != nil || ok {
|
||||
t.Fatalf("expected largest deleted, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createIndexedBox(t *testing.T, app *App, id string, ownerID string, ownerUsername string, size int64, password bool) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(boxstore.BoxPath(id), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
filename := "file-" + id[:4] + ".txt"
|
||||
if err := os.WriteFile(filepath.Join(boxstore.BoxPath(id), filename), []byte(strings.Repeat("x", int(size))), 0644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
manifest := models.BoxManifest{
|
||||
OwnerID: ownerID,
|
||||
OwnerUsername: ownerUsername,
|
||||
Files: []models.BoxFile{{
|
||||
ID: "abcdabcdabcdabcd",
|
||||
Name: filename,
|
||||
Size: size,
|
||||
Status: models.FileStatusReady,
|
||||
}},
|
||||
CreatedAt: time.Now().UTC().Add(-time.Duration(size) * time.Second),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
RetentionSecs: 3600,
|
||||
}
|
||||
if password {
|
||||
manifest.PasswordHash = "hash"
|
||||
manifest.AuthToken = "token"
|
||||
}
|
||||
if err := boxstore.WriteManifest(id, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
if err := app.store.UpsertBoxRecord(boxRecordFromManifest(id, manifest)); err != nil {
|
||||
t.Fatalf("UpsertBoxRecord returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountBoxes(router http.Handler, session metastore.Session, path string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountBoxesForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
61
lib/server/account_nav.go
Normal file
61
lib/server/account_nav.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AccountNavView struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
ActiveSection string
|
||||
AlertCount int
|
||||
AlertSeverity string
|
||||
CanViewBoxes bool
|
||||
CanViewAlerts bool
|
||||
CanViewUsers bool
|
||||
CanViewAPIKeys bool
|
||||
CanViewSettings bool
|
||||
}
|
||||
|
||||
func (app *App) accountNavView(ctx *gin.Context, activeSection string) AccountNavView {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
isAdmin := perms.AdminAccess
|
||||
|
||||
return AccountNavView{
|
||||
Username: app.currentAdminUsername(ctx),
|
||||
IsAdmin: isAdmin,
|
||||
ActiveSection: activeSection,
|
||||
AlertSeverity: "ok",
|
||||
CanViewBoxes: true,
|
||||
CanViewAlerts: true,
|
||||
CanViewUsers: perms.AdminUsersManage,
|
||||
CanViewAPIKeys: true,
|
||||
CanViewSettings: perms.AdminSettingsManage,
|
||||
}
|
||||
}
|
||||
|
||||
func currentAccountPermissions(ctx *gin.Context) metastore.EffectivePermissions {
|
||||
value, ok := ctx.Get("adminPerms")
|
||||
if !ok {
|
||||
return metastore.EffectivePermissions{}
|
||||
}
|
||||
perms, ok := value.(metastore.EffectivePermissions)
|
||||
if !ok {
|
||||
return metastore.EffectivePermissions{}
|
||||
}
|
||||
return perms
|
||||
}
|
||||
|
||||
func normalizeAlertSeverity(severity string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(severity))
|
||||
switch normalized {
|
||||
case "danger", "warning", "info", "ok":
|
||||
return normalized
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
253
lib/server/account_pages.go
Normal file
253
lib/server/account_pages.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AccountDashboardView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Stats AccountDashboardStats
|
||||
Statuses []accountStatusRow
|
||||
Alerts []accountAlertPreviewRow
|
||||
RecentBoxes []accountDashboardBoxRow
|
||||
RecentActivity []accountActivityRow
|
||||
ShowUsersStat bool
|
||||
CanManageBoxes bool
|
||||
CanManageUsers bool
|
||||
CanViewSettings bool
|
||||
HasAlertsPreview bool
|
||||
}
|
||||
|
||||
type AccountDashboardStats struct {
|
||||
ActiveBoxes int
|
||||
StorageUsedLabel string
|
||||
AlertCount int
|
||||
TotalUsers int
|
||||
ActiveUsers int
|
||||
DisabledUsers int
|
||||
}
|
||||
|
||||
type accountStatusRow struct {
|
||||
Label string
|
||||
Value string
|
||||
Severity string
|
||||
}
|
||||
|
||||
type accountAlertPreviewRow struct {
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type accountDashboardBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
CanManage bool
|
||||
}
|
||||
|
||||
type accountActivityRow struct {
|
||||
Time string
|
||||
Title string
|
||||
Meta string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountDashboard(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
view, err := app.GetAccountDashboard(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load account dashboard")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_dashboard.html", view)
|
||||
}
|
||||
|
||||
func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (AccountDashboardView, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
nav := app.accountNavView(ctx, "dashboard")
|
||||
|
||||
totalSize := int64(0)
|
||||
activeBoxes := 0
|
||||
recentBoxes := []accountDashboardBoxRow{}
|
||||
if perms.AdminBoxesView {
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
return AccountDashboardView{}, err
|
||||
}
|
||||
|
||||
recentBoxes = make([]accountDashboardBoxRow, 0, minInt(len(summaries), 10))
|
||||
for _, summary := range summaries {
|
||||
totalSize += summary.TotalSize
|
||||
if !summary.Expired {
|
||||
activeBoxes++
|
||||
}
|
||||
if len(recentBoxes) < 10 {
|
||||
recentBoxes = append(recentBoxes, accountDashboardBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Flags: accountBoxFlags(summary.Expired, summary.OneTimeDownload, summary.PasswordProtected),
|
||||
CanManage: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats := AccountDashboardStats{
|
||||
ActiveBoxes: activeBoxes,
|
||||
StorageUsedLabel: helpers.FormatBytes(totalSize),
|
||||
}
|
||||
alertPreview := []accountAlertPreviewRow{}
|
||||
if perms.AdminAccess {
|
||||
stats.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
nav.AlertCount = stats.AlertCount
|
||||
alertPreview = app.accountDashboardAlertPreview()
|
||||
}
|
||||
|
||||
showUsersStat := perms.AdminUsersManage
|
||||
if showUsersStat {
|
||||
users, err := app.store.ListUsers()
|
||||
if err != nil {
|
||||
return AccountDashboardView{}, err
|
||||
}
|
||||
stats.TotalUsers = len(users)
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AccountDashboardView{
|
||||
PageTitle: "WarpBox Account",
|
||||
WindowTitle: "WarpBox Account Control Panel",
|
||||
WindowIcon: "W",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Stats: stats,
|
||||
Statuses: app.accountDashboardStatuses(),
|
||||
Alerts: alertPreview,
|
||||
RecentBoxes: recentBoxes,
|
||||
RecentActivity: accountPlaceholderActivity(actor, ctx),
|
||||
ShowUsersStat: showUsersStat,
|
||||
CanManageBoxes: perms.AdminBoxesView,
|
||||
CanManageUsers: perms.AdminUsersManage,
|
||||
CanViewSettings: perms.AdminSettingsManage,
|
||||
HasAlertsPreview: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) accountDashboardStatuses() []accountStatusRow {
|
||||
return []accountStatusRow{
|
||||
{Label: "Guest uploads", Value: enabledLabel(app.config.GuestUploadsEnabled), Severity: boolSeverity(app.config.GuestUploadsEnabled)},
|
||||
{Label: "API", Value: enabledLabel(app.config.APIEnabled), Severity: boolSeverity(app.config.APIEnabled)},
|
||||
{Label: "ZIP downloads", Value: enabledLabel(app.config.ZipDownloadsEnabled), Severity: boolSeverity(app.config.ZipDownloadsEnabled)},
|
||||
{Label: "One-time boxes", Value: enabledLabel(app.config.OneTimeDownloadsEnabled), Severity: boolSeverity(app.config.OneTimeDownloadsEnabled)},
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) accountDashboardAlertPreview() []accountAlertPreviewRow {
|
||||
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen, Sort: "severity"})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rows := make([]accountAlertPreviewRow, 0, minInt(len(alerts), 6))
|
||||
for _, alert := range alerts {
|
||||
if len(rows) == 6 {
|
||||
break
|
||||
}
|
||||
rows = append(rows, accountAlertPreviewRow{
|
||||
Severity: alert.Severity,
|
||||
Title: alert.Title,
|
||||
Detail: alert.Description,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
|
||||
now := time.Now().UTC()
|
||||
if value, ok := ctx.Get("accountSession"); ok {
|
||||
if session, ok := value.(metastore.Session); ok {
|
||||
now = session.CreatedAt
|
||||
}
|
||||
}
|
||||
return []accountActivityRow{
|
||||
{
|
||||
Time: formatAdminTime(now),
|
||||
Title: "Signed in",
|
||||
Meta: actor.Username + " opened the account dashboard.",
|
||||
},
|
||||
{
|
||||
Time: "pending",
|
||||
Title: "Audit log not implemented",
|
||||
Meta: "Recent account activity will use the audit model in a later pass.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
|
||||
flags := []string{}
|
||||
if expired {
|
||||
flags = append(flags, "expired")
|
||||
}
|
||||
if oneTime {
|
||||
flags = append(flags, "one-time")
|
||||
}
|
||||
if passwordProtected {
|
||||
flags = append(flags, "password")
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return "normal"
|
||||
}
|
||||
out := flags[0]
|
||||
for _, flag := range flags[1:] {
|
||||
out += ", " + flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func enabledLabel(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func boolSeverity(enabled bool) string {
|
||||
if enabled {
|
||||
return "ok"
|
||||
}
|
||||
return "warn"
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
506
lib/server/account_settings.go
Normal file
506
lib/server/account_settings.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type SettingsView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Groups []SettingsGroupView
|
||||
OverridesAllowed bool
|
||||
CanEdit bool
|
||||
Error string
|
||||
Notice string
|
||||
}
|
||||
|
||||
type SettingsGroupView struct {
|
||||
Key string
|
||||
Label string
|
||||
Description string
|
||||
Rows []SettingsRowView
|
||||
}
|
||||
|
||||
type SettingsRowView struct {
|
||||
Key string
|
||||
Label string
|
||||
Description string
|
||||
Type config.SettingType
|
||||
Value string
|
||||
DisplayValue string
|
||||
Source string
|
||||
EnvName string
|
||||
Editable bool
|
||||
LockedReason string
|
||||
Future bool
|
||||
}
|
||||
|
||||
type SettingsBackup struct {
|
||||
Version int `json:"version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Applied int `json:"applied"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type settingsMeta struct {
|
||||
Group string
|
||||
Description string
|
||||
Units string
|
||||
Future bool
|
||||
}
|
||||
|
||||
var settingsGroups = []SettingsGroupView{
|
||||
{Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."},
|
||||
{Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."},
|
||||
{Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."},
|
||||
{Key: "accounts", Label: "Accounts", Description: "Session and account defaults."},
|
||||
{Key: "api", Label: "API", Description: "API surface toggles."},
|
||||
{Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."},
|
||||
{Key: "workers", Label: "Workers", Description: "Background worker timing."},
|
||||
{Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."},
|
||||
}
|
||||
|
||||
var settingsMetadata = map[string]settingsMeta{
|
||||
config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."},
|
||||
config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"},
|
||||
config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"},
|
||||
config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."},
|
||||
config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."},
|
||||
config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"},
|
||||
config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."},
|
||||
config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"},
|
||||
config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"},
|
||||
config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."},
|
||||
config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."},
|
||||
config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"},
|
||||
config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."},
|
||||
config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."},
|
||||
config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"},
|
||||
config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"},
|
||||
config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"},
|
||||
config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."},
|
||||
config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"},
|
||||
config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."},
|
||||
config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."},
|
||||
config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."},
|
||||
config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"},
|
||||
config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"},
|
||||
config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."},
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettings(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.ListSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.Request.ParseForm(); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "")
|
||||
return
|
||||
}
|
||||
|
||||
editable := map[string]config.SettingDefinition{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = def
|
||||
}
|
||||
for key := range ctx.Request.PostForm {
|
||||
if key == "csrf_token" {
|
||||
continue
|
||||
}
|
||||
if _, ok := editable[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := config.Definition(key); ok {
|
||||
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "")
|
||||
return
|
||||
}
|
||||
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "")
|
||||
return
|
||||
}
|
||||
|
||||
changes := map[string]string{}
|
||||
for _, def := range editable {
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value := "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
changes[def.Key] = value
|
||||
continue
|
||||
}
|
||||
if _, exists := ctx.GetPostForm(def.Key); exists {
|
||||
changes[def.Key] = ctx.PostForm(def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.UpdateSettings(ctx, actor, changes); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsReset(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
backup, err := app.ExportSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`)
|
||||
ctx.JSON(http.StatusOK, backup)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsImport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") {
|
||||
ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"})
|
||||
return
|
||||
}
|
||||
var backup SettingsBackup
|
||||
if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"})
|
||||
return
|
||||
}
|
||||
result, err := app.ImportSettings(ctx, actor, backup)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return SettingsView{}, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride)
|
||||
groups := make([]SettingsGroupView, 0, len(settingsGroups))
|
||||
for _, group := range settingsGroups {
|
||||
copyGroup := group
|
||||
copyGroup.Rows = rows[group.Key]
|
||||
groups = append(groups, copyGroup)
|
||||
}
|
||||
|
||||
return SettingsView{
|
||||
PageTitle: "WarpBox Settings",
|
||||
WindowTitle: "WarpBox Account Settings",
|
||||
WindowIcon: "S",
|
||||
PageScripts: []string{"/static/js/account-settings.js"},
|
||||
AccountNav: app.accountNavView(ctx, "settings"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Groups: groups,
|
||||
OverridesAllowed: app.config.AllowAdminSettingsOverride,
|
||||
CanEdit: app.config.AllowAdminSettingsOverride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return fmt.Errorf("admin settings overrides are disabled")
|
||||
}
|
||||
if err := validateSettingChanges(changes); err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range changes {
|
||||
if err := app.store.SetSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return app.reloadRuntimeConfig()
|
||||
}
|
||||
|
||||
func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
def, ok := config.Definition(strings.TrimSpace(key))
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown setting %q", key)
|
||||
}
|
||||
if !def.Editable || def.HardLimit {
|
||||
return fmt.Errorf("setting %q cannot be reset from account settings", key)
|
||||
}
|
||||
if err := app.store.DeleteSetting(def.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.reloadRuntimeConfig()
|
||||
}
|
||||
|
||||
func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return SettingsBackup{}, fmt.Errorf("permission denied")
|
||||
}
|
||||
settings := map[string]string{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
settings[def.Key] = app.config.SettingValue(def.Key)
|
||||
}
|
||||
return SettingsBackup{
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Settings: settings,
|
||||
Metadata: map[string]string{
|
||||
"app": "WarpBox",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return ImportResult{}, fmt.Errorf("admin settings overrides are disabled")
|
||||
}
|
||||
if backup.Settings == nil {
|
||||
return ImportResult{}, fmt.Errorf("settings backup has no settings")
|
||||
}
|
||||
if err := validateSettingChanges(backup.Settings); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(backup.Settings))
|
||||
for key := range backup.Settings {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if err := app.store.SetSetting(key, backup.Settings[key]); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
}
|
||||
if err := app.reloadRuntimeConfig(); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return ImportResult{Applied: len(keys), Keys: keys}, nil
|
||||
}
|
||||
|
||||
func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) {
|
||||
view, err := app.ListSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
view.Error = errorMessage
|
||||
view.Notice = notice
|
||||
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||
}
|
||||
|
||||
func (app *App) requireSettingsEdit(ctx *gin.Context) error {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView {
|
||||
out := map[string][]SettingsRowView{}
|
||||
for _, row := range app.config.SettingRows() {
|
||||
meta := settingsMetadata[row.Definition.Key]
|
||||
group := meta.Group
|
||||
if group == "" {
|
||||
group = "accounts"
|
||||
}
|
||||
editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit
|
||||
out[group] = append(out[group], SettingsRowView{
|
||||
Key: row.Definition.Key,
|
||||
Label: row.Definition.Label,
|
||||
Description: meta.Description,
|
||||
Type: row.Definition.Type,
|
||||
Value: row.Value,
|
||||
DisplayValue: settingDisplayValue(row.Value, meta.Units),
|
||||
Source: settingSourceLabel(row.Source, row.Definition),
|
||||
EnvName: row.Definition.EnvName,
|
||||
Editable: editable,
|
||||
LockedReason: settingLockedReason(row.Definition, canEdit),
|
||||
Future: meta.Future,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSettingChanges(changes map[string]string) error {
|
||||
if len(changes) == 0 {
|
||||
return fmt.Errorf("no settings provided")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range changes {
|
||||
if _, ok := config.Definition(key); !ok {
|
||||
return fmt.Errorf("unknown setting %q", key)
|
||||
}
|
||||
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) reloadRuntimeConfig() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := app.store.ListSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
app.config = cfg
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingSourceLabel(source config.Source, def config.SettingDefinition) string {
|
||||
if def.HardLimit {
|
||||
return "hard env"
|
||||
}
|
||||
if !def.Editable {
|
||||
return "locked"
|
||||
}
|
||||
switch source {
|
||||
case config.SourceDB:
|
||||
return "override"
|
||||
case config.SourceEnv:
|
||||
return "env"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func settingLockedReason(def config.SettingDefinition, canEdit bool) string {
|
||||
if !canEdit {
|
||||
return "settings changes disabled"
|
||||
}
|
||||
if def.HardLimit {
|
||||
return "hard environment limit"
|
||||
}
|
||||
if !def.Editable {
|
||||
return "runtime editing not supported"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func settingDisplayValue(value string, units string) string {
|
||||
switch units {
|
||||
case "bytes":
|
||||
parsed, ok := parseInt64String(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if parsed == 0 {
|
||||
return "unlimited"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value)
|
||||
case "duration":
|
||||
parsed, ok := parseInt64String(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value)
|
||||
case "milliseconds":
|
||||
return value + " ms"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64String(value string) (int64, bool) {
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func formatBytesForSettings(value int64) string {
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||
size := float64(value)
|
||||
unit := 0
|
||||
for size >= 1024 && unit < len(units)-1 {
|
||||
size /= 1024
|
||||
unit++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", size, units[unit])
|
||||
}
|
||||
|
||||
func formatDurationForSettings(seconds int64) string {
|
||||
switch {
|
||||
case seconds == 0:
|
||||
return "none"
|
||||
case seconds%86400 == 0:
|
||||
return fmt.Sprintf("%d days", seconds/86400)
|
||||
case seconds%3600 == 0:
|
||||
return fmt.Sprintf("%d hours", seconds/3600)
|
||||
case seconds%60 == 0:
|
||||
return fmt.Sprintf("%d minutes", seconds/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d seconds", seconds)
|
||||
}
|
||||
}
|
||||
197
lib/server/account_settings_test.go
Normal file
197
lib/server/account_settings_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountSettingsPermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("regular", "regular@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsPageLoadsForAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, text := range []string{"Uploads", "Downloads", "Box policy", "Save Settings"} {
|
||||
if !strings.Contains(response.Body.String(), text) {
|
||||
t.Fatalf("expected settings page to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsValidUpdate(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingAPIEnabled, "false")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected settings redirect, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if app.config.APIEnabled {
|
||||
t.Fatal("expected API setting to be disabled")
|
||||
}
|
||||
value, ok, err := app.store.GetSetting(config.SettingAPIEnabled)
|
||||
if err != nil || !ok || value != "false" {
|
||||
t.Fatalf("expected API setting override false, got value=%q ok=%v err=%v", value, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsInvalidUpdate(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingSessionTTLSeconds, "1")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "must be at least 60") {
|
||||
t.Fatal("expected validation error in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsLockedSettingCannotChange(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingGlobalMaxFileSizeBytes, "1")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "locked") {
|
||||
t.Fatal("expected locked setting error")
|
||||
}
|
||||
if value, ok, err := app.store.GetSetting(config.SettingGlobalMaxFileSizeBytes); err != nil || ok || value != "" {
|
||||
t.Fatalf("expected no locked setting override, got value=%q ok=%v err=%v", value, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsImportRejectsUnknownOrInvalidSettings(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"version":1,"settings":{"not_real":"true"}}`,
|
||||
`{"version":1,"settings":{"session_ttl_seconds":"1"}}`,
|
||||
} {
|
||||
response := postAccountSettingsJSON(router, session, body)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad import for %s, got %d", body, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsImportAppliesValidSettings(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
response := postAccountSettingsJSON(router, session, `{"version":1,"settings":{"api_enabled":"false","box_owner_max_refresh_count":"7"}}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected import success, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if app.config.APIEnabled {
|
||||
t.Fatal("expected imported API setting to be disabled")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshCount != 7 {
|
||||
t.Fatalf("expected imported box owner refresh count 7, got %d", app.config.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsExportShape(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings/export.json", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected export success, got %d", response.Code)
|
||||
}
|
||||
var backup SettingsBackup
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &backup); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if backup.Version != 1 {
|
||||
t.Fatalf("expected version 1, got %d", backup.Version)
|
||||
}
|
||||
if _, ok := backup.Settings[config.SettingBoxOwnerMaxRefreshCount]; !ok {
|
||||
t.Fatal("expected export to include box owner policy setting")
|
||||
}
|
||||
if _, ok := backup.Settings[config.SettingDataDir]; ok {
|
||||
t.Fatal("did not expect locked data dir in export settings")
|
||||
}
|
||||
}
|
||||
|
||||
func createAccountTestSession(t *testing.T, app *App, user metastore.User) metastore.Session {
|
||||
t.Helper()
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func postAccountSettingsForm(router http.Handler, session metastore.Session, form url.Values) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/settings", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountSettingsJSON(router http.Handler, session metastore.Session, body string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/settings/import.json", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-CSRF-Token", session.CSRFToken)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
858
lib/server/account_test.go
Normal file
858
lib/server/account_test.go
Normal file
@@ -0,0 +1,858 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountLoginSuccess(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "secret")
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/account" {
|
||||
t.Fatalf("expected redirect to /account, got %q", location)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie == nil || cookie.Value == "" {
|
||||
t.Fatal("expected account session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLoginFailure(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "wrong")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected failed login to render form, got %d", response.Code)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||
t.Fatal("did not expect account session cookie")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||
t.Fatal("expected login failure message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDisabledUserLoginFailure(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
user.Disabled = true
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
t.Fatalf("UpdateUser returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "secret")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected disabled login to render form, got %d", response.Code)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||
t.Fatal("did not expect account session cookie")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||
t.Fatal("expected login failure message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLogoutRequiresCSRF(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/logout", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardRequiresAuth(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected dashboard redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/account/login" {
|
||||
t.Fatalf("expected redirect to /account/login, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardLoadsForBootstrapAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"Dashboard", "Recent Boxes", "Users"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected dashboard body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardHidesAdminOnlyLinksForRegularUser(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("maya", "maya@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{">Users<", ">Settings<"} {
|
||||
if strings.Contains(body, text) {
|
||||
t.Fatalf("expected dashboard body to hide %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEntryRedirectsToAccount(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
cases := map[string]string{
|
||||
"/admin/login": "/account/login",
|
||||
"/admin": "/account",
|
||||
}
|
||||
for path, wantLocation := range cases {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected %s redirect, got %d", path, response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != wantLocation {
|
||||
t.Fatalf("expected %s to redirect to %s, got %q", path, wantLocation, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupAccountTestApp(t *testing.T) (*App, metastore.User) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
restoreUploadRoot := boxstore.UploadRoot()
|
||||
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
|
||||
boxstore.SetUploadRoot(t.TempDir())
|
||||
|
||||
store, err := metastore.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminPassword = "secret"
|
||||
cfg.AdminEmail = "admin@example.test"
|
||||
cfg.AdminEnabled = config.AdminEnabledAuto
|
||||
cfg.SessionTTLSeconds = 3600
|
||||
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||
}
|
||||
if bootstrap.AdminUser == nil {
|
||||
t.Fatal("expected bootstrap admin user")
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
store: store,
|
||||
adminLoginEnabled: bootstrap.AdminLoginEnabled,
|
||||
}
|
||||
return app, *bootstrap.AdminUser
|
||||
}
|
||||
|
||||
func setupAccountTestRouter(t *testing.T, app *App) *gin.Engine {
|
||||
t.Helper()
|
||||
router := gin.New()
|
||||
templates, err := template.ParseGlob(filepath.Join("..", "..", "templates", "*.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGlob returned error: %v", err)
|
||||
}
|
||||
router.SetHTMLTemplate(templates)
|
||||
app.registerAccountRoutes(router)
|
||||
app.registerAdminRoutes(router)
|
||||
return router
|
||||
}
|
||||
|
||||
func postAccountLogin(router *gin.Engine, username string, password string) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/login", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUsersPagePermissionDeniedForNoPerms(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("viewer", "viewer@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "Permission denied") {
|
||||
t.Fatal("expected permission denied message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageLoadsForAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"WarpBox Users", "Create or Invite", "Total users"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected users page body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageListFilters(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
_, err := app.store.CreateUserWithPassword("beta", "beta@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users?q=beta", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "beta") {
|
||||
t.Fatal("expected filtered list to contain beta")
|
||||
}
|
||||
if !strings.Contains(body, "1 matching user(s)") {
|
||||
t.Fatalf("expected 1 matching user for beta filter, got body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "create")
|
||||
form.Set("username", "newuser")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("password", "password123")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after create, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("newuser")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected newuser to exist")
|
||||
}
|
||||
if created.Disabled {
|
||||
t.Fatal("expected newuser to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInviteCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "invite")
|
||||
form.Set("username", "invited")
|
||||
form.Set("email", "invited@example.test")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after invite, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("invited")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected invited user to exist")
|
||||
}
|
||||
if !created.Disabled {
|
||||
t.Fatal("expected invited user to be disabled")
|
||||
}
|
||||
if !strings.HasPrefix(created.PasswordHash, "invite/") {
|
||||
t.Fatal("expected invited user to have invite prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableRejectsSelf(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", user.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "cannot disable yourself") && !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected self-disable rejection, got location %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableProtectsFinalAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok || adminTag.ID == "" {
|
||||
t.Fatal("expected admin tag")
|
||||
}
|
||||
second, err := app.store.CreateUserWithPassword("admin2", "admin2@example.test", "secret", []string{adminTag.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
|
||||
// Admin tries to disable the other admin (not self): should work since self remains.
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", second.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected success redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "user(s) disabled") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
|
||||
// Verify admin2 is disabled, admin1 still active
|
||||
disabledUser, ok, _ := app.store.GetUserByUsername("admin2")
|
||||
if !ok || !disabledUser.Disabled {
|
||||
t.Fatal("expected admin2 to be disabled")
|
||||
}
|
||||
adminUser, ok, _ := app.store.GetUserByUsername("admin")
|
||||
if !ok || adminUser.Disabled {
|
||||
t.Fatal("expected admin to remain active")
|
||||
}
|
||||
|
||||
// Now try to disable the only remaining admin (self): should be rejected
|
||||
form2 := url.Values{}
|
||||
form2.Set("csrf_token", session.CSRFToken)
|
||||
form2.Set("selected_ids", user.ID)
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form2.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
resp2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp2, req2)
|
||||
|
||||
if resp2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect for self-disable rejection, got %d", resp2.Code)
|
||||
}
|
||||
loc2 := resp2.Header().Get("Location")
|
||||
if !strings.Contains(loc2, "cannot disable yourself") {
|
||||
t.Fatalf("expected self-disable rejection, got %q", loc2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPagePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
regular, err := app.store.CreateUserWithPassword("viewer2", "viewer2@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(regular.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+regular.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPageLoadsForAdmin(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("edittarget", "edittarget@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+target.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"edittarget", "Access rights", "Limits", "Setting overrides", "Resolved policy"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditProfileUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("origname", "orig@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", "newname")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("admin_note", "test note")
|
||||
form.Set("state", "active")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found after update")
|
||||
}
|
||||
if updated.Username != "newname" {
|
||||
t.Fatalf("expected username newname, got %q", updated.Username)
|
||||
}
|
||||
if updated.AdminNote != "test note" {
|
||||
t.Fatalf("expected admin note, got %q", updated.AdminNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditAccessRightsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("perm_target", "perm@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("upload_allowed", "1")
|
||||
form.Set("zip_download_allowed", "1")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok || updated.PermOverrides == nil {
|
||||
t.Fatal("expected perm overrides to be set")
|
||||
}
|
||||
if updated.PermOverrides.UploadAllowed == nil || !*updated.PermOverrides.UploadAllowed {
|
||||
t.Fatal("expected upload_allowed=true")
|
||||
}
|
||||
if updated.PermOverrides.ZipDownloadAllowed == nil || !*updated.PermOverrides.ZipDownloadAllowed {
|
||||
t.Fatal("expected zip_download_allowed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLimitsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("limits_target", "limits@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "1073741824")
|
||||
form.Set("max_expiry_seconds", "86400")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found")
|
||||
}
|
||||
if updated.MaxFileSizeBytes == nil || *updated.MaxFileSizeBytes != 1073741824 {
|
||||
t.Fatalf("expected max_file_size_bytes=1073741824, got %v", updated.MaxFileSizeBytes)
|
||||
}
|
||||
if updated.MaxExpirySeconds == nil || *updated.MaxExpirySeconds != 86400 {
|
||||
t.Fatalf("expected max_expiry_seconds=86400, got %v", updated.MaxExpirySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditInvalidLimitRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("badlimit", "badlimit@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "notanumber")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditSelfDisableRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
form.Set("state", "disabled")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
|
||||
unchanged, _, _ := app.store.GetUser(admin.ID)
|
||||
if unchanged.Disabled {
|
||||
t.Fatal("admin should not have been disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLastAdminProtected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
// try to remove admin tag from the only admin via is_admin=0 (unchecked)
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
// is_admin NOT set → wantsAdmin=false → should be blocked
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error for last-admin removal, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPasswordReset(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("resetme", "resetme@example.test", "oldpass", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/password/reset", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "success=") {
|
||||
t.Fatalf("expected success redirect, got %q", location)
|
||||
}
|
||||
|
||||
updated, _, _ := app.store.GetUser(target.ID)
|
||||
if metastore.VerifyPassword(updated.PasswordHash, "oldpass") {
|
||||
t.Fatal("old password should no longer work after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditRevokeSessions(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("revokeme", "revokeme@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
targetSession, err := app.store.CreateSession(target.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
adminSession, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", adminSession.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/sessions/revoke", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: adminSession.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
_, stillValid, _ := app.store.GetSession(targetSession.Token)
|
||||
if stillValid {
|
||||
t.Fatal("target session should have been revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRevokeSessions(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
other, err := app.store.CreateUserWithPassword("other", "other@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
if _, err := app.store.CreateSession(other.ID, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", other.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/revoke-sessions", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "Sessions revoked") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
}
|
||||
557
lib/server/account_user_edit.go
Normal file
557
lib/server/account_user_edit.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type UserEditView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Target metastore.User
|
||||
Tags []metastore.Tag
|
||||
AdminTagID string
|
||||
IsAdmin bool
|
||||
IsPending bool
|
||||
Status string
|
||||
Perms metastore.EffectivePermissions
|
||||
PolicyJSON string
|
||||
CanManage bool
|
||||
IsSelf bool
|
||||
Error string
|
||||
Success string
|
||||
// precomputed display values
|
||||
TagNames string
|
||||
CreatedAtStr string
|
||||
UpdatedAtStr string
|
||||
MaxFileSizeStr string
|
||||
MaxBoxSizeStr string
|
||||
MaxExpiryStr string
|
||||
// precomputed perm override checkbox states
|
||||
Check map[string]bool
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// profile
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
|
||||
if username == "" {
|
||||
redirectUserEdit(ctx, userID, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// state (cannot change pending via this field)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
if !isPending {
|
||||
stateVal := ctx.PostForm("state")
|
||||
switch stateVal {
|
||||
case "disabled":
|
||||
if target.ID == actor.ID {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
if !target.Disabled {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
target.Disabled = true
|
||||
case "active":
|
||||
target.Disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// admin tag toggle
|
||||
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
|
||||
return
|
||||
}
|
||||
wantsAdmin := ctx.PostForm("is_admin") == "1"
|
||||
if adminTagOK {
|
||||
hasAdmin := containsString(target.TagIDs, adminTag.ID)
|
||||
if wantsAdmin && !hasAdmin {
|
||||
target.TagIDs = append(target.TagIDs, adminTag.ID)
|
||||
} else if !wantsAdmin && hasAdmin {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// per-user permission overrides
|
||||
target.PermOverrides = &metastore.UserPermOverrides{
|
||||
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
|
||||
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
|
||||
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
|
||||
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
|
||||
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
|
||||
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
|
||||
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
|
||||
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
|
||||
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
|
||||
}
|
||||
|
||||
// limits
|
||||
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxFileSizeBytes = v
|
||||
} else {
|
||||
target.MaxFileSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxBoxSizeBytes = v
|
||||
} else {
|
||||
target.MaxBoxSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxExpirySeconds = v
|
||||
} else {
|
||||
target.MaxExpirySeconds = nil
|
||||
}
|
||||
|
||||
target.Username = username
|
||||
target.Email = email
|
||||
target.AdminNote = adminNote
|
||||
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User saved.")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == actor.ID && disabled {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if disabled {
|
||||
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
target.Disabled = disabled
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not update user.", "")
|
||||
return
|
||||
}
|
||||
action := "enabled"
|
||||
if disabled {
|
||||
action = "disabled"
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User "+action+".")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
newPassword := randomPassword()
|
||||
hash, err := metastore.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not hash password.", "")
|
||||
return
|
||||
}
|
||||
target.PasswordHash = hash
|
||||
target.Disabled = false
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not reset password.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if err := app.store.RevokeUserSessions(userID); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
|
||||
}
|
||||
|
||||
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
return UserEditView{}, err
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
adminTagID := ""
|
||||
for _, t := range tags {
|
||||
if t.Name == metastore.AdminTagName {
|
||||
adminTagID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin := containsString(target.TagIDs, adminTagID)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
status := "active"
|
||||
if isPending {
|
||||
status = "pending"
|
||||
} else if target.Disabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
effectivePerms, _ := app.permissionsForUser(target)
|
||||
|
||||
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
|
||||
|
||||
// tag names
|
||||
tagNames := make([]string, 0, len(target.TagIDs))
|
||||
for _, tagID := range target.TagIDs {
|
||||
for _, t := range tags {
|
||||
if t.ID == tagID {
|
||||
tagNames = append(tagNames, t.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// perm override checkboxes
|
||||
checks := map[string]bool{
|
||||
"upload_allowed": effectivePerms.UploadAllowed,
|
||||
"manage_own_boxes": false,
|
||||
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
|
||||
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
|
||||
"renewable_allowed": effectivePerms.RenewableAllowed,
|
||||
"allow_password_protected": false,
|
||||
"renew_on_access": false,
|
||||
"renew_on_download": false,
|
||||
"allow_owner_box_editing": false,
|
||||
}
|
||||
if o := target.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
checks["upload_allowed"] = *o.UploadAllowed
|
||||
}
|
||||
if o.ManageOwnBoxes != nil {
|
||||
checks["manage_own_boxes"] = *o.ManageOwnBoxes
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
checks["renewable_allowed"] = *o.RenewableAllowed
|
||||
}
|
||||
if o.AllowPasswordProtected != nil {
|
||||
checks["allow_password_protected"] = *o.AllowPasswordProtected
|
||||
}
|
||||
if o.RenewOnAccess != nil {
|
||||
checks["renew_on_access"] = *o.RenewOnAccess
|
||||
}
|
||||
if o.RenewOnDownload != nil {
|
||||
checks["renew_on_download"] = *o.RenewOnDownload
|
||||
}
|
||||
if o.AllowOwnerBoxEditing != nil {
|
||||
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
return UserEditView{
|
||||
PageTitle: "Edit User — " + target.Username,
|
||||
WindowTitle: "User Edit — " + target.Username,
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-user-edit.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Target: target,
|
||||
Tags: tags,
|
||||
AdminTagID: adminTagID,
|
||||
IsAdmin: isAdmin,
|
||||
IsPending: isPending,
|
||||
Status: status,
|
||||
Perms: effectivePerms,
|
||||
PolicyJSON: policyJSON,
|
||||
CanManage: canManage,
|
||||
IsSelf: actor.ID == target.ID,
|
||||
Error: errMsg,
|
||||
Success: successMsg,
|
||||
TagNames: strings.Join(tagNames, ", "),
|
||||
CreatedAtStr: formatAdminTime(target.CreatedAt),
|
||||
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
|
||||
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
|
||||
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
|
||||
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
|
||||
Check: checks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
|
||||
type permMap struct {
|
||||
BoxesCreate bool `json:"boxes.create"`
|
||||
ManageOwn bool `json:"boxes.manage_own"`
|
||||
RefreshOwn bool `json:"boxes.refresh_own"`
|
||||
DownloadsZip bool `json:"downloads.zip"`
|
||||
DownloadsOneTime bool `json:"downloads.one_time"`
|
||||
AdminAccess bool `json:"admin.access"`
|
||||
AdminUsers bool `json:"admin.users.manage"`
|
||||
AdminSettings bool `json:"admin.settings.manage"`
|
||||
}
|
||||
type limitsMap struct {
|
||||
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
|
||||
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
|
||||
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
|
||||
}
|
||||
type overridesMap struct {
|
||||
AllowPassword bool `json:"allow_password_protected"`
|
||||
RenewOnAccess bool `json:"renew_on_access"`
|
||||
RenewOnDownload bool `json:"renew_on_download"`
|
||||
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
|
||||
}
|
||||
type preview struct {
|
||||
User string `json:"user"`
|
||||
Status string `json:"status"`
|
||||
Permissions permMap `json:"permissions"`
|
||||
Limits limitsMap `json:"limits"`
|
||||
Overrides overridesMap `json:"overrides"`
|
||||
}
|
||||
|
||||
manageOwn := false
|
||||
allowPwd := false
|
||||
renewAccess := false
|
||||
renewDownload := false
|
||||
allowOwnerEdit := false
|
||||
if overrides != nil {
|
||||
if overrides.ManageOwnBoxes != nil {
|
||||
manageOwn = *overrides.ManageOwnBoxes
|
||||
}
|
||||
if overrides.AllowPasswordProtected != nil {
|
||||
allowPwd = *overrides.AllowPasswordProtected
|
||||
}
|
||||
if overrides.RenewOnAccess != nil {
|
||||
renewAccess = *overrides.RenewOnAccess
|
||||
}
|
||||
if overrides.RenewOnDownload != nil {
|
||||
renewDownload = *overrides.RenewOnDownload
|
||||
}
|
||||
if overrides.AllowOwnerBoxEditing != nil {
|
||||
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
p := preview{
|
||||
User: username,
|
||||
Status: status,
|
||||
Permissions: permMap{
|
||||
BoxesCreate: perms.UploadAllowed,
|
||||
ManageOwn: manageOwn,
|
||||
RefreshOwn: perms.RenewableAllowed,
|
||||
DownloadsZip: perms.ZipDownloadAllowed,
|
||||
DownloadsOneTime: perms.OneTimeDownloadAllowed,
|
||||
AdminAccess: perms.AdminAccess,
|
||||
AdminUsers: perms.AdminUsersManage,
|
||||
AdminSettings: perms.AdminSettingsManage,
|
||||
},
|
||||
Limits: limitsMap{
|
||||
MaxFileSizeBytes: perms.MaxFileSizeBytes,
|
||||
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
|
||||
MaxExpirySeconds: perms.MaxExpirySeconds,
|
||||
},
|
||||
Overrides: overridesMap{
|
||||
AllowPassword: allowPwd,
|
||||
RenewOnAccess: renewAccess,
|
||||
RenewOnDownload: renewDownload,
|
||||
AllowOwnerEdit: allowOwnerEdit,
|
||||
},
|
||||
}
|
||||
data, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (app *App) checkLastAdminDisable(ids []string) error {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removing := 0
|
||||
for _, id := range ids {
|
||||
u, found, _ := app.store.GetUser(id)
|
||||
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
|
||||
removing++
|
||||
}
|
||||
}
|
||||
if adminCount-removing < 1 {
|
||||
return fmt.Errorf("cannot remove the last active administrator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func int64PtrStr(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", *v)
|
||||
}
|
||||
|
||||
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
|
||||
base := "/account/users/" + userID
|
||||
if errMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
|
||||
} else if successMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
|
||||
} else {
|
||||
ctx.Redirect(http.StatusSeeOther, base)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeString(slice []string, s string) []string {
|
||||
out := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
195
lib/server/admin_auth.go
Normal file
195
lib/server/admin_auth.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const adminSessionCookie = "warpbox_admin_session"
|
||||
|
||||
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||
if app.isAdminSessionValid(ctx) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
return
|
||||
}
|
||||
app.renderAdminLogin(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled {
|
||||
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
user, ok, err := app.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||
return
|
||||
}
|
||||
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||
return
|
||||
}
|
||||
if !perms.AdminAccess {
|
||||
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
||||
_ = app.store.DeleteSession(token)
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
}
|
||||
func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if !validAdminCSRF(ctx, session) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil || !perms.AdminAccess {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return false
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
return err == nil && perms.AdminAccess
|
||||
}
|
||||
|
||||
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
||||
tags, err := app.store.TagsByID(user.TagIDs)
|
||||
if err != nil {
|
||||
return metastore.EffectivePermissions{}, err
|
||||
}
|
||||
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
||||
}
|
||||
|
||||
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
||||
value, ok := ctx.Get("adminPerms")
|
||||
if !ok {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
perms, ok := value.(metastore.EffectivePermissions)
|
||||
if !ok || !allowed(perms) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user.Username
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
||||
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
||||
if token, ok := value.(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func noStoreAdminHeaders(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.Header("Pragma", "no-cache")
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
||||
switch ctx.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
|
||||
token := ctx.PostForm("csrf_token")
|
||||
if token == "" {
|
||||
token = ctx.GetHeader("X-CSRF-Token")
|
||||
}
|
||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||
}
|
||||
|
||||
func subtleConstantTimeEqual(a string, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
63
lib/server/admin_boxes.go
Normal file
63
lib/server/admin_boxes.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Expired bool
|
||||
OneTimeDownload bool
|
||||
PasswordProtected bool
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]adminBoxRow, 0, len(summaries))
|
||||
totalSize := int64(0)
|
||||
expiredCount := 0
|
||||
for _, summary := range summaries {
|
||||
totalSize += summary.TotalSize
|
||||
if summary.Expired {
|
||||
expiredCount++
|
||||
}
|
||||
rows = append(rows, adminBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Expired: summary.Expired,
|
||||
OneTimeDownload: summary.OneTimeDownload,
|
||||
PasswordProtected: summary.PasswordProtected,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
||||
"AdminSection": "boxes",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"Boxes": rows,
|
||||
"TotalBoxes": len(rows),
|
||||
"TotalStorage": helpers.FormatBytes(totalSize),
|
||||
"ExpiredBoxes": expiredCount,
|
||||
})
|
||||
}
|
||||
14
lib/server/admin_dashboard.go
Normal file
14
lib/server/admin_dashboard.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
})
|
||||
}
|
||||
73
lib/server/admin_format.go
Normal file
73
lib/server/admin_format.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseOptionalInt64(raw string) (*int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("must be an integer")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, errors.New("must be at least 0")
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func optionalInt64Label(value *int64) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatInt(*value, 10)
|
||||
}
|
||||
|
||||
func joinInt64s(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, strconv.FormatInt(value, 10))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatAdminTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
37
lib/server/admin_routes.go
Normal file
37
lib/server/admin_routes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(noStoreAdminHeaders)
|
||||
admin.GET("/login", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
})
|
||||
admin.POST("/login", app.handleAdminLoginPost)
|
||||
admin.GET("", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
})
|
||||
admin.GET("/", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
})
|
||||
|
||||
protected := admin.Group("")
|
||||
protected.Use(app.requireAdminSession)
|
||||
protected.POST("/logout", app.handleAdminLogout)
|
||||
protected.GET("/boxes", app.handleAdminBoxes)
|
||||
protected.GET("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.POST("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.GET("/tags", app.handleAdminTags)
|
||||
protected.POST("/tags", app.handleAdminTagsPost)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||
}
|
||||
58
lib/server/admin_settings.go
Normal file
58
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
value := ctx.PostForm(def.Key)
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value = "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
}
|
||||
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if err := app.store.SetSetting(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
||||
"AdminSection": "settings",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Rows": app.config.SettingRows(),
|
||||
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
122
lib/server/admin_tags.go
Normal file
122
lib/server/admin_tags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminTagRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Protected bool
|
||||
AdminAccess bool
|
||||
UploadAllowed bool
|
||||
ZipDownloadAllowed bool
|
||||
OneTimeDownloadAllowed bool
|
||||
RenewableAllowed bool
|
||||
MaxFileSizeBytes string
|
||||
MaxBoxSizeBytes string
|
||||
AllowedExpirySeconds string
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTags(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminTags(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := parseTagPermissions(ctx)
|
||||
if err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
tag := metastore.Tag{
|
||||
Name: ctx.PostForm("name"),
|
||||
Description: ctx.PostForm("description"),
|
||||
Permissions: perms,
|
||||
}
|
||||
if err := app.store.CreateTag(&tag); err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
return
|
||||
}
|
||||
sort.Slice(tags, func(i int, j int) bool {
|
||||
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
||||
})
|
||||
rows := make([]adminTagRow, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
rows = append(rows, adminTagRow{
|
||||
ID: tag.ID,
|
||||
Name: tag.Name,
|
||||
Description: tag.Description,
|
||||
Protected: tag.Protected,
|
||||
AdminAccess: tag.Permissions.AdminAccess,
|
||||
UploadAllowed: tag.Permissions.UploadAllowed,
|
||||
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
||||
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
||||
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
||||
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
||||
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
||||
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
||||
"AdminSection": "tags",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Tags": rows,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
||||
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
||||
}
|
||||
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
||||
}
|
||||
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, err
|
||||
}
|
||||
return metastore.TagPermissions{
|
||||
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
||||
AllowedExpirySeconds: expirySeconds,
|
||||
MaxFileSizeBytes: maxFileSize,
|
||||
MaxBoxSizeBytes: maxBoxSize,
|
||||
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
||||
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
||||
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
||||
AdminAccess: checkbox(ctx, "admin_access"),
|
||||
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
||||
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
||||
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkbox(ctx *gin.Context, name string) bool {
|
||||
return ctx.PostForm(name) == "true"
|
||||
}
|
||||
393
lib/server/admin_users.go
Normal file
393
lib/server/admin_users.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const defaultUserPageSize = 12
|
||||
|
||||
type UsersIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters UserFiltersView
|
||||
Rows []metastore.UserRow
|
||||
Stats metastore.UserPageStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
CanManage bool
|
||||
Tags []metastore.Tag
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
type UserFiltersView struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsers(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
filters := userFiltersFromRequest(ctx)
|
||||
pageReq := userPageFromRequest(ctx)
|
||||
|
||||
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
|
||||
currentID := actor.ID
|
||||
for i := range userPage.Rows {
|
||||
if userPage.Rows[i].ID == currentID {
|
||||
userPage.Rows[i].IsCurrent = true
|
||||
}
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
view := UsersIndexView{
|
||||
PageTitle: "WarpBox Users",
|
||||
WindowTitle: "WarpBox Users",
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-users.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: UserFiltersView{
|
||||
Query: filters.Query,
|
||||
Status: filters.Status,
|
||||
Role: filters.Role,
|
||||
Sort: filters.Sort,
|
||||
PageSize: pageReq.PageSize,
|
||||
},
|
||||
Rows: userPage.Rows,
|
||||
Stats: userPage.Stats,
|
||||
Page: userPage.Page,
|
||||
PageSize: userPage.PageSize,
|
||||
Total: userPage.Total,
|
||||
TotalPages: userPage.TotalPages,
|
||||
HasPrev: userPage.HasPrev,
|
||||
HasNext: userPage.HasNext,
|
||||
CanManage: perms.AdminUsersManage,
|
||||
Tags: tags,
|
||||
Error: ctx.Query("error"),
|
||||
Success: ctx.Query("success"),
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "account_users.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.PostForm("action")
|
||||
switch action {
|
||||
case "create":
|
||||
app.handleAccountUsersCreate(ctx, actor)
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Unknown action", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||
role := strings.TrimSpace(ctx.PostForm("role"))
|
||||
|
||||
if username == "" {
|
||||
redirectAccountUsers(ctx, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
redirectAccountUsers(ctx, "Email is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
var tagIDs []string
|
||||
if role != "" && role != "all" {
|
||||
tag, ok, err := app.store.GetTagByName(role)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not look up role.", "")
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "invite":
|
||||
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
case "create":
|
||||
password := strings.TrimSpace(ctx.PostForm("password"))
|
||||
autoGen := false
|
||||
if password == "" {
|
||||
password = randomPassword()
|
||||
autoGen = true
|
||||
}
|
||||
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("User %s created.", user.Username)
|
||||
if autoGen {
|
||||
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
|
||||
}
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Select create or invite mode.", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if id == actor.ID {
|
||||
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
disableAdminCount := 0
|
||||
for _, id := range ids {
|
||||
user, ok, err := app.store.GetUser(id)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if !user.Disabled {
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTag.ID {
|
||||
disableAdminCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if adminCount-disableAdminCount < 1 {
|
||||
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not update users.", "")
|
||||
return
|
||||
}
|
||||
|
||||
action := "disabled"
|
||||
if !disabled {
|
||||
action = "enabled"
|
||||
}
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == "" {
|
||||
redirectAccountUsers(ctx, "User ID is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
|
||||
}
|
||||
|
||||
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
|
||||
return metastore.UserFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
Role: strings.TrimSpace(ctx.Query("role")),
|
||||
Sort: strings.TrimSpace(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
pageSize := defaultUserPageSize
|
||||
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
return metastore.UserPageRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSelectedIDs(ctx *gin.Context) []string {
|
||||
raw := ctx.PostForm("selected_ids")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
|
||||
redirectURL := "/account/users"
|
||||
if errorMsg != "" {
|
||||
redirectURL = "/account/users?error=" + errorMsg
|
||||
} else if successMsg != "" {
|
||||
redirectURL = "/account/users?success=" + successMsg
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, redirectURL)
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
result := make([]byte, 16)
|
||||
for i := range result {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "changeme123"
|
||||
}
|
||||
result[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
135
lib/server/box_auth.go
Normal file
135
lib/server/box_auth.go
Normal 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
281
lib/server/downloads.go
Normal 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)
|
||||
}
|
||||
219
lib/server/one_time_test.go
Normal file
219
lib/server/one_time_test.go
Normal 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
100
lib/server/pages.go
Normal 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
49
lib/server/retention.go
Normal 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]
|
||||
}
|
||||
79
lib/server/security_test.go
Normal file
79
lib/server/security_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
|
||||
restoreUploadRoot := boxstore.UploadRoot()
|
||||
defer boxstore.SetUploadRoot(restoreUploadRoot)
|
||||
boxstore.SetUploadRoot(t.TempDir())
|
||||
|
||||
boxID := "0123456789abcdef0123456789abcdef"
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
manifest := models.BoxManifest{
|
||||
Files: []models.BoxFile{{ID: "0123456789abcdef", Name: "file.txt", Status: models.FileStatusWait}},
|
||||
ExpiresAt: time.Now().UTC().Add(-time.Second),
|
||||
}
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
app := &App{config: &config.Config{}}
|
||||
if err := app.validateManifestFileUpload(boxID, "0123456789abcdef", 1); err == nil {
|
||||
t.Fatal("expected expired box upload to be rejected")
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(boxID)); !os.IsNotExist(err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,726 +1,91 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/routing"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadRoot = "data/uploads"
|
||||
boxManifestFile = ".warpbox.json"
|
||||
fileStatusFailed = "failed"
|
||||
fileStatusReady = "complete"
|
||||
fileStatusWait = "pending"
|
||||
fileStatusWork = "uploading"
|
||||
)
|
||||
|
||||
var boxManifestMu sync.Mutex
|
||||
|
||||
type boxFile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SizeLabel string `json:"size_label"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"status_label"`
|
||||
Title string `json:"title"`
|
||||
IconPath string `json:"icon_path"`
|
||||
DownloadPath string `json:"download_path"`
|
||||
UploadPath string `json:"upload_path"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
type boxManifest struct {
|
||||
Files []boxFile `json:"files"`
|
||||
}
|
||||
|
||||
type createBoxRequest struct {
|
||||
Files []createBoxFileRequest `json:"files"`
|
||||
}
|
||||
|
||||
type createBoxFileRequest struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type updateFileStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
type App struct {
|
||||
config *config.Config
|
||||
store *metastore.Store
|
||||
adminLoginEnabled bool
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
store, err := metastore.Open(cfg.DBDir)
|
||||
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)
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("apply settings overrides: %w", err)
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap admin metadata: %w", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
store: store,
|
||||
adminLoginEnabled: bootstrap.AdminLoginEnabled,
|
||||
}
|
||||
|
||||
router := gin.Default()
|
||||
router.LoadHTMLGlob("templates/*.html")
|
||||
|
||||
router.GET("/", func(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "index.html", gin.H{})
|
||||
})
|
||||
|
||||
router.GET("/box/:id", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !validBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := listBoxFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
||||
"BoxID": boxID,
|
||||
"Files": files,
|
||||
"FileCount": len(files),
|
||||
"DownloadAll": "/box/" + boxID + "/download",
|
||||
})
|
||||
})
|
||||
|
||||
router.GET("/box/:id/status", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !validBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := listBoxFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"box_id": boxID,
|
||||
"files": files,
|
||||
})
|
||||
})
|
||||
|
||||
router.GET("/box/:id/download", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !validBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := listBoxFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Header("Content-Type", "application/zip")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||
|
||||
zipWriter := zip.NewWriter(ctx.Writer)
|
||||
defer zipWriter.Close()
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := addFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.GET("/box/:id/files/:filename", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
filename, ok := safeFilename(ctx.Param("filename"))
|
||||
if !validBoxID(boxID) || !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(boxPath(boxID), filename)
|
||||
if !strings.HasPrefix(path, boxPath(boxID)+string(filepath.Separator)) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ctx.String(http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.FileAttachment(path, filename)
|
||||
})
|
||||
|
||||
router.POST("/box", func(ctx *gin.Context) {
|
||||
boxID, err := newBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
var request createBoxRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := createBoxManifest(boxID, request.Files)
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
router.POST("/box/:id/files/:file_id/upload", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !validBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
markManifestFileStatus(boxID, fileID, fileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := saveManifestUploadedFile(ctx, boxID, fileID, file)
|
||||
if err != nil {
|
||||
markManifestFileStatus(boxID, fileID, 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,
|
||||
})
|
||||
})
|
||||
|
||||
router.POST("/box/:id/files/:file_id/status", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !validBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
var request updateFileStatusRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := markManifestFileStatus(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})
|
||||
})
|
||||
|
||||
router.POST("/box/:id/upload", func(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !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
|
||||
}
|
||||
|
||||
savedFile, err := saveUploadedFile(ctx, 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,
|
||||
})
|
||||
})
|
||||
|
||||
router.POST("/upload", func(ctx *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
boxID, err := newBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles := make([]gin.H, 0, len(files))
|
||||
|
||||
for _, file := range files {
|
||||
savedFile, err := saveUploadedFile(ctx, boxID, file)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
routing.Register(router, routing.Handlers{
|
||||
Index: app.handleIndex,
|
||||
ShowBox: app.handleShowBox,
|
||||
BoxLogin: handleBoxLogin,
|
||||
BoxLoginPost: handleBoxLoginPost,
|
||||
BoxStatus: app.handleBoxStatus,
|
||||
DownloadBox: app.handleDownloadBox,
|
||||
DownloadFile: app.handleDownloadFile,
|
||||
DownloadThumbnail: app.handleDownloadThumbnail,
|
||||
CreateBox: app.handleCreateBox,
|
||||
ManifestFileUpload: app.handleManifestFileUpload,
|
||||
FileStatusUpdate: app.handleFileStatusUpdate,
|
||||
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||
LegacyUpload: app.handleLegacyUpload,
|
||||
})
|
||||
app.registerAccountRoutes(router)
|
||||
app.registerAdminRoutes(router)
|
||||
|
||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||
compressed.Static("/static", "./static")
|
||||
|
||||
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
||||
|
||||
return router.Run(addr)
|
||||
}
|
||||
|
||||
func newBoxID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func newFileID() (string, error) {
|
||||
bytes := make([]byte, 8)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func validBoxID(boxID string) bool {
|
||||
if len(boxID) != 32 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, character := range boxID {
|
||||
if !strings.ContainsRune("0123456789abcdef", character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func boxPath(boxID string) string {
|
||||
return filepath.Join(uploadRoot, boxID)
|
||||
}
|
||||
|
||||
func manifestPath(boxID string) string {
|
||||
return filepath.Join(boxPath(boxID), boxManifestFile)
|
||||
}
|
||||
|
||||
func listBoxFiles(boxID string) ([]boxFile, error) {
|
||||
if manifest, err := readBoxManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
||||
files := make([]boxFile, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
files = append(files, decorateBoxFile(boxID, file))
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(boxPath(boxID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]boxFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || entry.Name() == boxManifestFile {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name)
|
||||
files = append(files, decorateBoxFile(boxID, boxFile{
|
||||
ID: name,
|
||||
Name: name,
|
||||
Size: info.Size(),
|
||||
MimeType: mimeType,
|
||||
Status: fileStatusReady,
|
||||
}))
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func createBoxManifest(boxID string, requests []createBoxFileRequest) ([]boxFile, error) {
|
||||
usedNames := make(map[string]int, len(requests))
|
||||
files := make([]boxFile, 0, len(requests))
|
||||
|
||||
for _, request := range requests {
|
||||
filename, ok := safeFilename(request.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid filename")
|
||||
}
|
||||
|
||||
filename = uniqueManifestFilename(filename, usedNames)
|
||||
fileID, err := newFileID()
|
||||
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, boxFile{
|
||||
ID: fileID,
|
||||
Name: filename,
|
||||
Size: request.Size,
|
||||
MimeType: mimeType,
|
||||
Status: fileStatusWait,
|
||||
})
|
||||
}
|
||||
|
||||
manifest := boxManifest{Files: files}
|
||||
if err := writeBoxManifest(boxID, manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decoratedFiles := make([]boxFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
decoratedFiles = append(decoratedFiles, decorateBoxFile(boxID, file))
|
||||
}
|
||||
|
||||
return decoratedFiles, nil
|
||||
}
|
||||
|
||||
func readBoxManifest(boxID string) (boxManifest, error) {
|
||||
boxManifestMu.Lock()
|
||||
defer boxManifestMu.Unlock()
|
||||
|
||||
return readBoxManifestUnlocked(boxID)
|
||||
}
|
||||
|
||||
func readBoxManifestUnlocked(boxID string) (boxManifest, error) {
|
||||
var manifest 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 writeBoxManifest(boxID string, manifest boxManifest) error {
|
||||
boxManifestMu.Lock()
|
||||
defer boxManifestMu.Unlock()
|
||||
|
||||
return writeBoxManifestUnlocked(boxID, manifest)
|
||||
}
|
||||
|
||||
func writeBoxManifestUnlocked(boxID string, manifest boxManifest) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(manifestPath(boxID), data, 0644)
|
||||
}
|
||||
|
||||
func markManifestFileStatus(boxID string, fileID string, status string) (boxFile, error) {
|
||||
if status != fileStatusWait && status != fileStatusWork && status != fileStatusReady && status != fileStatusFailed {
|
||||
return boxFile{}, fmt.Errorf("Invalid file status")
|
||||
}
|
||||
|
||||
boxManifestMu.Lock()
|
||||
defer boxManifestMu.Unlock()
|
||||
|
||||
manifest, err := readBoxManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return boxFile{}, err
|
||||
}
|
||||
|
||||
for index, file := range manifest.Files {
|
||||
if file.ID != fileID {
|
||||
continue
|
||||
}
|
||||
|
||||
manifest.Files[index].Status = status
|
||||
if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
|
||||
return boxFile{}, err
|
||||
}
|
||||
|
||||
return decorateBoxFile(boxID, manifest.Files[index]), nil
|
||||
}
|
||||
|
||||
return boxFile{}, fmt.Errorf("File not found")
|
||||
}
|
||||
|
||||
func decorateBoxFile(boxID string, file boxFile) boxFile {
|
||||
if file.MimeType == "" {
|
||||
file.MimeType = mimeTypeForFile(filepath.Join(boxPath(boxID), file.Name), file.Name)
|
||||
}
|
||||
|
||||
if file.SizeLabel == "" {
|
||||
file.SizeLabel = formatBytes(file.Size)
|
||||
}
|
||||
|
||||
file.IconPath = iconForMimeType(file.MimeType, file.Name)
|
||||
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
||||
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
||||
file.IsComplete = file.Status == fileStatusReady
|
||||
|
||||
switch file.Status {
|
||||
case fileStatusReady:
|
||||
file.StatusLabel = "Ready"
|
||||
file.Title = "Download " + file.Name
|
||||
case fileStatusFailed:
|
||||
file.StatusLabel = "Failed"
|
||||
file.Title = "Failed to upload"
|
||||
case fileStatusWork:
|
||||
file.StatusLabel = "Loading"
|
||||
file.Title = "Loading"
|
||||
default:
|
||||
file.Status = fileStatusWait
|
||||
file.StatusLabel = "Waiting"
|
||||
file.Title = "Loading"
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
||||
path := filepath.Join(boxPath(boxID), filename)
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
destination, err := zipWriter.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
}
|
||||
|
||||
func saveManifestUploadedFile(ctx *gin.Context, boxID string, fileID string, file *multipart.FileHeader) (boxFile, error) {
|
||||
boxManifestMu.Lock()
|
||||
defer boxManifestMu.Unlock()
|
||||
|
||||
manifest, err := readBoxManifestUnlocked(boxID)
|
||||
if err != nil {
|
||||
return boxFile{}, err
|
||||
}
|
||||
|
||||
fileIndex := -1
|
||||
for index, manifestFile := range manifest.Files {
|
||||
if manifestFile.ID == fileID {
|
||||
fileIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fileIndex < 0 {
|
||||
return boxFile{}, fmt.Errorf("File not found")
|
||||
}
|
||||
|
||||
filename := manifest.Files[fileIndex].Name
|
||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
||||
return boxFile{}, fmt.Errorf("Could not prepare upload box")
|
||||
}
|
||||
|
||||
destination := filepath.Join(boxPath(boxID), filename)
|
||||
if err := ctx.SaveUploadedFile(file, destination); err != nil {
|
||||
manifest.Files[fileIndex].Status = fileStatusFailed
|
||||
writeBoxManifestUnlocked(boxID, manifest)
|
||||
return boxFile{}, fmt.Errorf("Could not save uploaded file")
|
||||
}
|
||||
|
||||
manifest.Files[fileIndex].Size = file.Size
|
||||
manifest.Files[fileIndex].MimeType = mimeTypeForFile(destination, filename)
|
||||
manifest.Files[fileIndex].Status = fileStatusReady
|
||||
if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
|
||||
return boxFile{}, err
|
||||
}
|
||||
|
||||
return decorateBoxFile(boxID, manifest.Files[fileIndex]), nil
|
||||
}
|
||||
|
||||
func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) {
|
||||
filename, ok := safeFilename(file.Filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Invalid filename")
|
||||
}
|
||||
|
||||
boxPath := boxPath(boxID)
|
||||
if err := os.MkdirAll(boxPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("Could not prepare upload box")
|
||||
}
|
||||
|
||||
filename = uniqueFilename(boxPath, filename)
|
||||
destination := filepath.Join(boxPath, filename)
|
||||
if err := ctx.SaveUploadedFile(file, destination); err != nil {
|
||||
return nil, fmt.Errorf("Could not save uploaded file")
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"name": filename,
|
||||
"size": file.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeFilename(name string) (string, bool) {
|
||||
filename := filepath.Base(name)
|
||||
filename = strings.TrimSpace(filename)
|
||||
return filename, filename != "" && filename != "." && filename != string(filepath.Separator)
|
||||
}
|
||||
|
||||
func uniqueFilename(directory string, filename string) string {
|
||||
if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) {
|
||||
return filename
|
||||
}
|
||||
|
||||
extension := filepath.Ext(filename)
|
||||
base := strings.TrimSuffix(filename, extension)
|
||||
for count := 2; ; count++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, count, extension)
|
||||
if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueManifestFilename(filename string, usedNames map[string]int) string {
|
||||
count := usedNames[filename]
|
||||
usedNames[filename] = count + 1
|
||||
|
||||
if count == 0 {
|
||||
return filename
|
||||
}
|
||||
|
||||
extension := filepath.Ext(filename)
|
||||
base := strings.TrimSuffix(filename, extension)
|
||||
return fmt.Sprintf("%s-%d%s", base, count+1, extension)
|
||||
}
|
||||
|
||||
func mimeTypeForFile(path string, filename string) string {
|
||||
if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buffer := make([]byte, 512)
|
||||
bytesRead, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
return http.DetectContentType(buffer[:bytesRead])
|
||||
}
|
||||
|
||||
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 formatBytes(bytes int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB"}
|
||||
size := float64(bytes)
|
||||
unitIndex := 0
|
||||
|
||||
for size >= 1024 && unitIndex < len(units)-1 {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
if unitIndex == 0 {
|
||||
return fmt.Sprintf("%d %s", bytes, units[unitIndex])
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
|
||||
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
|
||||
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
|
||||
}
|
||||
|
||||
240
lib/server/uploads.go
Normal file
240
lib/server/uploads.go
Normal file
@@ -0,0 +1,240 @@
|
||||
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
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
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
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
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
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
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)
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||
}
|
||||
155
lib/server/validation.go
Normal file
155
lib/server/validation.go
Normal 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
|
||||
}
|
||||
42
run.sh
Executable file
42
run.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/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
|
||||
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
|
||||
|
||||
# Download-page refresh and thumbnail worker tuning.
|
||||
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
|
||||
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
||||
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
||||
|
||||
# Data location.
|
||||
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
||||
|
||||
# Admin Area
|
||||
export WARPBOX_ADMIN_ENABLED="${WARPBOX_ADMIN_ENABLED:-true}"
|
||||
export WARPBOX_ADMIN_PASSWORD="${WARPBOX_ADMIN_PASSWORD:-123}"
|
||||
|
||||
# Option to run via Docker Compose
|
||||
if [ "${1:-}" = "--docker" ]; then
|
||||
docker-compose up --build
|
||||
exit 0
|
||||
fi
|
||||
|
||||
go run ./cmd run
|
||||
BIN
static/WarpBoxLogo.png
Normal file
BIN
static/WarpBoxLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
2034
static/css/account.css
Normal file
2034
static/css/account.css
Normal file
File diff suppressed because it is too large
Load Diff
132
static/css/admin.css
Normal file
132
static/css/admin.css
Normal file
@@ -0,0 +1,132 @@
|
||||
body {
|
||||
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;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
min-height: 88px;
|
||||
padding: 12px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.admin-link strong,
|
||||
.admin-link span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-link span {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-form-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-form-row input,
|
||||
.admin-form-row textarea,
|
||||
.admin-form-row select {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
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-family: inherit;
|
||||
}
|
||||
|
||||
.admin-checks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-checks label {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
padding: 8px;
|
||||
border: 1px solid #800;
|
||||
background: #ffdede;
|
||||
}
|
||||
|
||||
.admin-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-summary span {
|
||||
padding: 6px 8px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
@@ -15,81 +15,423 @@
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PixelOperatorMono';
|
||||
src: url('/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf');
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PixeloidSans';
|
||||
src: url('/static/fonts/pixeloid_sans/PixeloidSans.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PixeloidSans';
|
||||
src: url('/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf');
|
||||
font-weight: bold;
|
||||
font-family: 'MonoCraft';
|
||||
src: url('/static/fonts/Monocraft.ttf');
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'PixeloidSans', 'PixelOperator', sans-serif, Arial, Helvetica;
|
||||
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||
font-smooth: never;
|
||||
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: geometricPrecision;
|
||||
image-rendering: pixelated;
|
||||
|
||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Normal\ Select.cur'), auto;
|
||||
|
||||
--base-font-size: 14px;
|
||||
|
||||
/* Colours */
|
||||
--base-font-size: 13px;
|
||||
--ui-scale: 1;
|
||||
--w98-blue: #000078;
|
||||
--w98-blue-gradient: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
--w98-blue-gradient: linear-gradient(90deg, #000078 0%, #000078 28%, #0f80cd 50%, #000078 72%, #000078 100%);
|
||||
--w98-gray: #c0c0c0;
|
||||
--w98-gray2: #a6a6a6;
|
||||
--w98-gray-gradient: linear-gradient(to bottom, #fff, 95%, #c0c0c0);
|
||||
|
||||
scroll-behavior: smooth;
|
||||
--ok: #008000;
|
||||
--danger: #800000;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
label[for],
|
||||
.win98-button:not(:disabled) {
|
||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), auto;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
textarea,
|
||||
[contenteditable="true"] {
|
||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #c0c0c0 #808080;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
font-size: var(--base-font-size);
|
||||
color: white;
|
||||
background-color: #000;
|
||||
color: #ffffff;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: #000000;
|
||||
background-image: url('/static/img/bg/stars1.gif');
|
||||
background-repeat: repeat;
|
||||
background-size: auto;
|
||||
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
button,
|
||||
label[for],
|
||||
.menu-button,
|
||||
.win98-button:not(:disabled),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="file"],
|
||||
textarea {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px dotted #000078;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
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;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb,
|
||||
::-webkit-scrollbar-button:single-button {
|
||||
background: #c0c0c0;
|
||||
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;
|
||||
}
|
||||
|
||||
::-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 {
|
||||
min-width: 92px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
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-size: 13px;
|
||||
line-height: 13px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.win98-button:disabled,
|
||||
.win98-button[aria-disabled="true"],
|
||||
button:disabled,
|
||||
button[aria-disabled="true"],
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.win98-button:disabled,
|
||||
.win98-button[aria-disabled="true"] {
|
||||
color: #808080;
|
||||
text-shadow: 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.win98-button:active:not(:disabled):not([aria-disabled="true"]),
|
||||
.win98-control:active,
|
||||
.menu-button[aria-expanded="true"] {
|
||||
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;
|
||||
}
|
||||
|
||||
.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-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 90px);
|
||||
margin: 0 6px 6px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
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: 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;
|
||||
cursor: text;
|
||||
box-sizing: border-box;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
font-family: 'MonoCraft', 'PixelOperatorMono', monospace;
|
||||
}
|
||||
|
||||
.popup-window.is-properties-popup {
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.popup-window.is-preview-popup {
|
||||
width: min(760px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.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: 90;
|
||||
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;
|
||||
}
|
||||
|
||||
@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; } }
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
:root { --base-font-size: 14px; --ui-scale: 1.2; }
|
||||
}
|
||||
|
||||
@media (min-width: 2048px) {
|
||||
:root { --base-font-size: 15px; --ui-scale: 1.36; }
|
||||
}
|
||||
|
||||
@media (min-width: 2560px) {
|
||||
:root { --base-font-size: 16px; --ui-scale: 1.58; }
|
||||
}
|
||||
|
||||
@media (min-width: 3200px) {
|
||||
: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,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,48 @@
|
||||
.box-window {
|
||||
width: 640px;
|
||||
height: 460px;
|
||||
width: min(860px, calc(100vw - 36px));
|
||||
height: min(560px, calc(100vh - 36px));
|
||||
zoom: var(--ui-scale);
|
||||
}
|
||||
|
||||
.box-toolbar {
|
||||
display: flex;
|
||||
body.fit-window .box-window {
|
||||
width: min(980px, calc(100vw / var(--ui-scale) - 20px));
|
||||
height: min(720px, calc(100vh / var(--ui-scale) - 20px));
|
||||
}
|
||||
|
||||
.box-command-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
min-height: 40px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box-toolbar-button {
|
||||
width: 116px;
|
||||
width: auto;
|
||||
min-width: 158px;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.box-toolbar-button img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.box-address {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 6px;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.box-address code {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
@@ -44,6 +50,33 @@
|
||||
border-right: 1px solid #dfdfdf;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.win98-window.popup-window {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.win98-window.popup-window.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.box-meta {
|
||||
min-height: 24px;
|
||||
padding: 0 8px 6px;
|
||||
color: #333333;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.box-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.box-panel {
|
||||
@@ -51,6 +84,10 @@
|
||||
min-height: 0;
|
||||
margin: 0 8px 8px;
|
||||
overflow: auto;
|
||||
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);
|
||||
}
|
||||
|
||||
.box-file-grid {
|
||||
@@ -68,7 +105,6 @@
|
||||
grid-template-rows: 34px 18px 28px;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 6px;
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
@@ -111,6 +147,14 @@
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.box-file.has-thumbnail .box-file-icon {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
background: #ffffff;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
||||
.box-file-name,
|
||||
.box-file-meta {
|
||||
width: 100%;
|
||||
@@ -148,6 +192,106 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.box-context-menu {
|
||||
position: fixed;
|
||||
min-width: 168px;
|
||||
display: none;
|
||||
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);
|
||||
z-index: 95;
|
||||
}
|
||||
|
||||
.box-context-menu.is-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.box-context-menu button {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
color: #000000;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.box-context-menu button:hover,
|
||||
.box-context-menu button:focus-visible {
|
||||
color: #ffffff;
|
||||
background: #000078;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.box-context-menu img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.properties-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 7px 10px;
|
||||
padding: 10px;
|
||||
background: #dfdfdf;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #ffffff;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.properties-grid dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.properties-grid dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: min(680px, 100%);
|
||||
min-height: 260px;
|
||||
max-height: min(520px, calc(100vh - 160px));
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background: #000000;
|
||||
border: 1px solid #808080;
|
||||
}
|
||||
|
||||
.preview-frame.is-text {
|
||||
min-height: 240px;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
color: #00ff66;
|
||||
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
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;
|
||||
@@ -164,6 +308,7 @@
|
||||
main {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.box-window {
|
||||
@@ -171,6 +316,7 @@
|
||||
height: 100dvh;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
.box-titlebar {
|
||||
@@ -182,6 +328,15 @@
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.box-command-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.box-address {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.box-panel {
|
||||
margin: 0 6px 8px;
|
||||
}
|
||||
|
||||
117
static/css/components/buttons.css
Normal file
117
static/css/components/buttons.css
Normal 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;
|
||||
}
|
||||
38
static/css/components/toast.css
Normal file
38
static/css/components/toast.css
Normal 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;
|
||||
}
|
||||
133
static/css/login.css
Normal file
133
static/css/login.css
Normal file
@@ -0,0 +1,133 @@
|
||||
.login-window {
|
||||
width: 420px;
|
||||
height: 248px;
|
||||
zoom: var(--ui-scale);
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
flex: 1;
|
||||
margin: 8px;
|
||||
padding: 12px;
|
||||
background-color: #dfdfdf;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.18) 0 1px, transparent 1px 5px);
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #808080;
|
||||
border-bottom: 1px solid #808080;
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.login-alert img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.login-alert p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-row {
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
padding: 2px 5px;
|
||||
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-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.login-input[readonly] {
|
||||
color: #555555;
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin: 2px 0 0 90px;
|
||||
color: #800000;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.login-actions .win98-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-statusbar {
|
||||
grid-template-columns: 1fr 96px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
main {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.login-window {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
zoom: 1;
|
||||
}
|
||||
|
||||
.login-titlebar {
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
margin: 8px 6px;
|
||||
}
|
||||
|
||||
.login-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
.upload-window {
|
||||
width: 520px;
|
||||
height: 486px;
|
||||
}
|
||||
|
||||
.upload-form {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin: 0 8px 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
flex: 0 0 auto;
|
||||
height: 118px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
background: #dfdfdf;
|
||||
border: 1px dotted #000000;
|
||||
}
|
||||
|
||||
.upload-dropzone.is-dragging {
|
||||
background: #c7d8f2;
|
||||
outline: 2px solid #000078;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.upload-dropzone:focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -5px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 2px solid #000000;
|
||||
box-shadow: inset -3px -3px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.upload-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #dfdfdf;
|
||||
border-left: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
}
|
||||
|
||||
.upload-primary {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.upload-secondary {
|
||||
font-size: 13px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.upload-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.upload-details {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
margin-top: 12px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #dfdfdf;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
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 #808080;
|
||||
border-left: 2px solid #808080;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
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;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.upload-result.is-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
pointer-events: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.upload-share-button {
|
||||
width: 72px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.upload-share-button:disabled {
|
||||
color: #808080;
|
||||
text-shadow: 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
.upload-empty-state {
|
||||
margin: 0;
|
||||
padding: 9px 8px;
|
||||
color: #555555;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.upload-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 82px;
|
||||
grid-template-rows: 20px 8px;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
font-size: 13px;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.upload-file-row:nth-child(even) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.upload-file-row.is-uploading,
|
||||
.upload-file-row.is-processing {
|
||||
animation: upload-row-loading 900ms steps(2, end) infinite;
|
||||
}
|
||||
|
||||
.upload-file-icon {
|
||||
grid-row: 1 / 3;
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -2px -2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.upload-file-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #dfdfdf;
|
||||
border-left: 1px solid #000000;
|
||||
border-bottom: 1px solid #000000;
|
||||
}
|
||||
|
||||
.upload-file-name,
|
||||
.upload-file-size {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-file-size {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2;
|
||||
display: block;
|
||||
height: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #808080;
|
||||
border-left: 1px solid #808080;
|
||||
border-right: 1px solid #dfdfdf;
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
}
|
||||
|
||||
.upload-progress-bar {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #000078;
|
||||
}
|
||||
|
||||
.upload-file-row.is-uploaded .upload-progress-bar {
|
||||
background: #008000;
|
||||
}
|
||||
|
||||
.upload-file-row.is-failed .upload-progress-bar {
|
||||
width: 100%;
|
||||
background: #800000;
|
||||
}
|
||||
|
||||
@keyframes upload-row-loading {
|
||||
0% {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.upload-overall {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.upload-overall-track {
|
||||
height: 18px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #808080;
|
||||
border-left: 2px solid #808080;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
|
||||
.upload-overall-bar {
|
||||
display: block;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background-color: #000078;
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#000078 0,
|
||||
#000078 10px,
|
||||
#c0c0c0 10px,
|
||||
#c0c0c0 12px
|
||||
);
|
||||
}
|
||||
|
||||
.upload-overall-percent {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.upload-statusbar {
|
||||
grid-template-columns: 1fr 96px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
main {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.upload-window {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.upload-titlebar {
|
||||
height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-menu {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
margin: 0 6px 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
height: 126px;
|
||||
min-height: 126px;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
grid-template-columns: 64px minmax(0, 1fr) 68px;
|
||||
}
|
||||
}
|
||||
36
static/css/upload/actions.css
Normal file
36
static/css/upload/actions.css
Normal 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;
|
||||
}
|
||||
101
static/css/upload/dialog-content.css
Normal file
101
static/css/upload/dialog-content.css
Normal 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;
|
||||
}
|
||||
95
static/css/upload/dialogs.css
Normal file
95
static/css/upload/dialogs.css
Normal 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;
|
||||
}
|
||||
|
||||
41
static/css/upload/folders.css
Normal file
41
static/css/upload/folders.css
Normal 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;
|
||||
}
|
||||
43
static/css/upload/layout.css
Normal file
43
static/css/upload/layout.css
Normal 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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user