refactor(code): Cleaned-up the code base
This commit is contained in:
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.
|
||||||
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.
|
||||||
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.
|
||||||
710
admin-overview.md
Normal file
710
admin-overview.md
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
# WarpBox Admin Overview
|
||||||
|
|
||||||
|
This document maps the current WarpBox admin area, explains how the existing pages and permission model work, then proposes an overhaul path for a more useful admin product: dashboard, account management, API keys, and a role/group based authorization system.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
WarpBox is a small self-hosted file sharing app. Its core product is deliberately simple:
|
||||||
|
|
||||||
|
- create temporary upload boxes
|
||||||
|
- upload one or more files
|
||||||
|
- optionally protect the box with a password
|
||||||
|
- share a generated link
|
||||||
|
- allow individual downloads or ZIP downloads
|
||||||
|
- support one-time ZIP handoff
|
||||||
|
- store uploads on the local filesystem
|
||||||
|
- store app metadata in local BadgerDB
|
||||||
|
|
||||||
|
The project has a strong retro desktop identity. The admin area should keep that personality, but become more operationally useful. Best direction: retro surface, modern information architecture. Admin actions should feel clear, forgiving, and inspectable rather than dense or mysterious.
|
||||||
|
|
||||||
|
## Current Admin Architecture
|
||||||
|
|
||||||
|
Admin routes are registered under `/admin` in `lib/server/admin.go`.
|
||||||
|
|
||||||
|
Current pages:
|
||||||
|
|
||||||
|
- `/admin/login`
|
||||||
|
- `/admin`
|
||||||
|
- `/admin/boxes`
|
||||||
|
- `/admin/users`
|
||||||
|
- `/admin/tags`
|
||||||
|
- `/admin/settings`
|
||||||
|
|
||||||
|
Admin templates live in:
|
||||||
|
|
||||||
|
- `templates/admin_login.html`
|
||||||
|
- `templates/admin.html`
|
||||||
|
- `templates/admin_boxes.html`
|
||||||
|
- `templates/admin_users.html`
|
||||||
|
- `templates/admin_tags.html`
|
||||||
|
- `templates/admin_settings.html`
|
||||||
|
|
||||||
|
Styling uses:
|
||||||
|
|
||||||
|
- `static/css/admin.css`
|
||||||
|
- shared retro UI styles from `static/css/app.css` and `static/css/window.css`
|
||||||
|
|
||||||
|
Metadata lives in BadgerDB through `lib/metastore`. Current admin-relevant records are:
|
||||||
|
|
||||||
|
- users
|
||||||
|
- tags
|
||||||
|
- sessions
|
||||||
|
- settings overrides
|
||||||
|
|
||||||
|
## Current Login And Session Flow
|
||||||
|
|
||||||
|
Admin login is bootstrapped from environment configuration:
|
||||||
|
|
||||||
|
- `WARPBOX_ADMIN_PASSWORD`
|
||||||
|
- `WARPBOX_ADMIN_USERNAME`
|
||||||
|
- `WARPBOX_ADMIN_EMAIL`
|
||||||
|
- `WARPBOX_ADMIN_ENABLED`
|
||||||
|
|
||||||
|
On startup, `BootstrapAdmin` ensures a protected `admin` tag exists. If a user matching the admin username already exists, the admin tag is attached to that user. If no user exists and `WARPBOX_ADMIN_PASSWORD` is set, a first admin user is created.
|
||||||
|
|
||||||
|
Login behavior:
|
||||||
|
|
||||||
|
- `GET /admin/login` shows login form when admin login is enabled.
|
||||||
|
- `POST /admin/login` checks username/password.
|
||||||
|
- password hashes use bcrypt.
|
||||||
|
- disabled users cannot log in.
|
||||||
|
- user must have effective `AdminAccess`.
|
||||||
|
- successful login creates a BadgerDB session with random session token and CSRF token.
|
||||||
|
- session cookie name is `warpbox_admin_session`.
|
||||||
|
- cookie path is `/admin`.
|
||||||
|
- cookie is HTTP-only.
|
||||||
|
- cookie Secure flag is controlled by `WARPBOX_ADMIN_COOKIE_SECURE`.
|
||||||
|
- session TTL is controlled by `WARPBOX_SESSION_TTL_SECONDS`.
|
||||||
|
|
||||||
|
All protected admin routes require:
|
||||||
|
|
||||||
|
- valid session cookie
|
||||||
|
- unexpired session
|
||||||
|
- valid CSRF token for non-GET requests
|
||||||
|
- existing enabled user
|
||||||
|
- effective `AdminAccess`
|
||||||
|
|
||||||
|
Admin responses set no-store headers and `X-Content-Type-Options: nosniff`.
|
||||||
|
|
||||||
|
## Current Dashboard Page
|
||||||
|
|
||||||
|
Route:
|
||||||
|
|
||||||
|
- `GET /admin`
|
||||||
|
|
||||||
|
Template:
|
||||||
|
|
||||||
|
- `templates/admin.html`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- shows signed-in username
|
||||||
|
- provides logout button
|
||||||
|
- shows four large links:
|
||||||
|
- Boxes
|
||||||
|
- Users
|
||||||
|
- Tags
|
||||||
|
- Settings
|
||||||
|
|
||||||
|
Current limitation:
|
||||||
|
|
||||||
|
- this is not a dashboard yet. It has no statistics, recent activity, health status, quota information, user counts, storage totals, warnings, or next actions.
|
||||||
|
|
||||||
|
Good future role:
|
||||||
|
|
||||||
|
- make this the admin home screen, with live operational summary and clear links into deeper management pages.
|
||||||
|
|
||||||
|
## Current Boxes Page
|
||||||
|
|
||||||
|
Route:
|
||||||
|
|
||||||
|
- `GET /admin/boxes`
|
||||||
|
|
||||||
|
Required permission:
|
||||||
|
|
||||||
|
- `AdminBoxesView`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- loads all box summaries from filesystem via `boxstore.ListBoxSummaries()`
|
||||||
|
- shows summary counters:
|
||||||
|
- total boxes
|
||||||
|
- total storage
|
||||||
|
- expired boxes
|
||||||
|
- table columns:
|
||||||
|
- box id
|
||||||
|
- file count
|
||||||
|
- size
|
||||||
|
- created time
|
||||||
|
- expiry time
|
||||||
|
- flags
|
||||||
|
- flags include:
|
||||||
|
- expired
|
||||||
|
- one-time
|
||||||
|
- password
|
||||||
|
- box id links to `/box/{id}`
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- no search
|
||||||
|
- no filters
|
||||||
|
- no pagination
|
||||||
|
- no delete action
|
||||||
|
- no bulk cleanup
|
||||||
|
- no storage trend
|
||||||
|
- no per-box detail drawer
|
||||||
|
- no visibility into failed uploads, incomplete boxes, thumbnail state, or one-time consumption state
|
||||||
|
- expired boxes are counted but not directly actionable
|
||||||
|
|
||||||
|
Good future role:
|
||||||
|
|
||||||
|
- operational file-sharing monitor and cleanup center.
|
||||||
|
|
||||||
|
## Current Users Page
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /admin/users`
|
||||||
|
- `POST /admin/users`
|
||||||
|
|
||||||
|
Required permission:
|
||||||
|
|
||||||
|
- `AdminUsersManage`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- create user with username, email, password, and selected tags
|
||||||
|
- list users sorted by username
|
||||||
|
- show username, email, assigned tags, created time, status
|
||||||
|
- enable/disable user
|
||||||
|
- active session user cannot disable themselves
|
||||||
|
|
||||||
|
Current user model:
|
||||||
|
|
||||||
|
- `ID`
|
||||||
|
- `Username`
|
||||||
|
- `Email`
|
||||||
|
- `PasswordHash`
|
||||||
|
- `TagIDs`
|
||||||
|
- `CreatedAt`
|
||||||
|
- `UpdatedAt`
|
||||||
|
- `Disabled`
|
||||||
|
- optional per-user max file size
|
||||||
|
- optional per-user max box size
|
||||||
|
- optional per-user max expiry seconds
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- no edit user page
|
||||||
|
- no password reset flow
|
||||||
|
- no email verification or invite flow
|
||||||
|
- no delete/archive user action
|
||||||
|
- no user detail screen
|
||||||
|
- no role preview
|
||||||
|
- no effective permissions display
|
||||||
|
- no API key management
|
||||||
|
- no account activity
|
||||||
|
- no per-user storage/upload stats
|
||||||
|
- per-user limit fields exist in the model but are not exposed in the admin UI
|
||||||
|
|
||||||
|
Good future role:
|
||||||
|
|
||||||
|
- user account command center, optimized for quick scanning, safe edits, and permission clarity.
|
||||||
|
|
||||||
|
## Current Tags Page
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /admin/tags`
|
||||||
|
- `POST /admin/tags`
|
||||||
|
|
||||||
|
Required permission:
|
||||||
|
|
||||||
|
- `AdminUsersManage`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- create a tag
|
||||||
|
- set description
|
||||||
|
- attach permission booleans
|
||||||
|
- attach optional limits
|
||||||
|
- list existing tags
|
||||||
|
- built-in `admin` tag is protected and forced to full admin permissions
|
||||||
|
|
||||||
|
Current tag permissions:
|
||||||
|
|
||||||
|
- upload allowed
|
||||||
|
- one-time download allowed
|
||||||
|
- ZIP download allowed
|
||||||
|
- renewable allowed
|
||||||
|
- admin access
|
||||||
|
- manage users
|
||||||
|
- manage settings
|
||||||
|
- view boxes
|
||||||
|
- max file size bytes
|
||||||
|
- max box size bytes
|
||||||
|
- allowed expiry seconds
|
||||||
|
- renew on access seconds exists in model but is not exposed in current tag form
|
||||||
|
- renew on download seconds exists in model but is not exposed in current tag form
|
||||||
|
|
||||||
|
Permission resolution:
|
||||||
|
|
||||||
|
- user permissions are resolved from all assigned tags plus user-level overrides
|
||||||
|
- boolean permissions are additive: any tag can grant a permission
|
||||||
|
- size limits use the more permissive value
|
||||||
|
- global max file/box limits still cap resolved user limits
|
||||||
|
- allowed expiry seconds are merged from all tags and sorted
|
||||||
|
- global feature flags can still disable ZIP or one-time downloads even if a tag allows them
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- tags mix labels, roles, groups, limits, and admin capability grants in one concept
|
||||||
|
- no edit tag page
|
||||||
|
- no delete tag action
|
||||||
|
- no user count per tag
|
||||||
|
- no permission preview
|
||||||
|
- no conflict detection
|
||||||
|
- no clear distinction between "role grants access" and "plan defines quotas"
|
||||||
|
- permission logic is powerful but hard to explain to administrators
|
||||||
|
|
||||||
|
Good future role:
|
||||||
|
|
||||||
|
- replace tags with explicit roles/groups/plans. Keep old tags only as migration input.
|
||||||
|
|
||||||
|
## Current Settings Page
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
- `GET /admin/settings`
|
||||||
|
- `POST /admin/settings`
|
||||||
|
|
||||||
|
Required permission:
|
||||||
|
|
||||||
|
- `AdminSettingsManage`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- shows configured settings table
|
||||||
|
- columns:
|
||||||
|
- setting
|
||||||
|
- value
|
||||||
|
- source
|
||||||
|
- environment variable name
|
||||||
|
- editable values can be overridden from admin UI when `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE=true`
|
||||||
|
- overrides are stored in BadgerDB
|
||||||
|
- runtime config is updated after save
|
||||||
|
|
||||||
|
Editable settings currently include:
|
||||||
|
|
||||||
|
- guest uploads enabled
|
||||||
|
- API enabled
|
||||||
|
- ZIP downloads enabled
|
||||||
|
- one-time downloads enabled
|
||||||
|
- one-time download expiry seconds
|
||||||
|
- renew on access enabled
|
||||||
|
- renew on download enabled
|
||||||
|
- default guest expiry seconds
|
||||||
|
- max guest expiry seconds
|
||||||
|
- default user max file size bytes
|
||||||
|
- default user max box size bytes
|
||||||
|
- session TTL seconds
|
||||||
|
- box poll interval milliseconds
|
||||||
|
- thumbnail batch size
|
||||||
|
- thumbnail interval seconds
|
||||||
|
|
||||||
|
Non-editable/hard settings include:
|
||||||
|
|
||||||
|
- data directory
|
||||||
|
- global max file size bytes
|
||||||
|
- global max box size bytes
|
||||||
|
- one-time download retry on failure
|
||||||
|
|
||||||
|
Current limitations:
|
||||||
|
|
||||||
|
- settings are presented as raw technical rows
|
||||||
|
- no grouping
|
||||||
|
- no descriptions
|
||||||
|
- no validation hints beyond backend errors
|
||||||
|
- byte fields require raw bytes
|
||||||
|
- duration fields require raw seconds/milliseconds
|
||||||
|
- no "restart required" or "runtime applied" distinction beyond current editability
|
||||||
|
- no audit trail for setting changes
|
||||||
|
|
||||||
|
Good future role:
|
||||||
|
|
||||||
|
- organized system configuration, with human units, clear safety boundaries, and change history.
|
||||||
|
|
||||||
|
## Current API Key State
|
||||||
|
|
||||||
|
There is visible API key UX in the upload page:
|
||||||
|
|
||||||
|
- "Use API key for larger quota"
|
||||||
|
- API key input
|
||||||
|
- local validation with regex
|
||||||
|
- value saved locally in browser localStorage
|
||||||
|
- cURL command includes `Authorization: Bearer YOUR_API_KEY` when enabled
|
||||||
|
|
||||||
|
Important current reality:
|
||||||
|
|
||||||
|
- no backend API key model was found
|
||||||
|
- no API key generation route was found
|
||||||
|
- no server-side Authorization bearer validation was found
|
||||||
|
- no per-user API key ownership or revocation exists yet
|
||||||
|
- no current upload flow applies user permissions from an API key
|
||||||
|
|
||||||
|
So API keys are a product placeholder, not a functional feature yet.
|
||||||
|
|
||||||
|
## Main Current Gaps
|
||||||
|
|
||||||
|
- Dashboard is only navigation.
|
||||||
|
- User management only creates and disables accounts.
|
||||||
|
- Tags are doing too many jobs.
|
||||||
|
- API keys are UI-only.
|
||||||
|
- Admin pages lack search, filters, pagination, and detail views.
|
||||||
|
- No audit log.
|
||||||
|
- No admin-visible system health.
|
||||||
|
- No storage cleanup controls.
|
||||||
|
- No account self-service area for non-admin users.
|
||||||
|
- Existing permission model is additive and permissive, which can surprise admins.
|
||||||
|
|
||||||
|
## Recommended Overhaul Direction
|
||||||
|
|
||||||
|
Build a full admin solution around four clear concepts:
|
||||||
|
|
||||||
|
- Dashboard: current health and useful actions.
|
||||||
|
- Accounts: people who can sign in or use API keys.
|
||||||
|
- Roles/Groups: permission bundles and admin capabilities.
|
||||||
|
- Plans/Limits: quotas and upload policy.
|
||||||
|
|
||||||
|
This separates identity from authorization from quotas. It should make the system easier to explain and safer to operate.
|
||||||
|
|
||||||
|
## Proposed Dashboard
|
||||||
|
|
||||||
|
Recommended dashboard cards:
|
||||||
|
|
||||||
|
- total active boxes
|
||||||
|
- total storage used
|
||||||
|
- expired boxes waiting cleanup
|
||||||
|
- boxes created today / last 24 hours
|
||||||
|
- uploads completed today / last 24 hours
|
||||||
|
- failed or incomplete uploads
|
||||||
|
- active users
|
||||||
|
- disabled users
|
||||||
|
- API keys active
|
||||||
|
- admin sessions active
|
||||||
|
- thumbnail queue / worker state
|
||||||
|
- current global limits
|
||||||
|
- guest uploads status
|
||||||
|
- ZIP downloads status
|
||||||
|
- one-time downloads status
|
||||||
|
|
||||||
|
Recommended dashboard sections:
|
||||||
|
|
||||||
|
- "Needs attention"
|
||||||
|
- expired boxes
|
||||||
|
- failed uploads
|
||||||
|
- one-time boxes stuck incomplete
|
||||||
|
- high storage usage
|
||||||
|
- disabled API setting while API key UI is visible
|
||||||
|
- "Recent boxes"
|
||||||
|
- latest boxes with flags and size
|
||||||
|
- "Recent admin activity"
|
||||||
|
- user created, role changed, setting changed, API key revoked
|
||||||
|
- "System"
|
||||||
|
- data directory
|
||||||
|
- BadgerDB status
|
||||||
|
- uploads directory size
|
||||||
|
- thumbnail worker timing
|
||||||
|
- config source summary
|
||||||
|
|
||||||
|
UX idea:
|
||||||
|
|
||||||
|
- dashboard should answer "is WarpBox healthy?", "what changed recently?", and "what should I do next?" in one glance.
|
||||||
|
|
||||||
|
## Proposed Account Management
|
||||||
|
|
||||||
|
Recommended account list:
|
||||||
|
|
||||||
|
- search by username/email
|
||||||
|
- filter by status, role/group, plan, API key presence
|
||||||
|
- columns:
|
||||||
|
- user
|
||||||
|
- email
|
||||||
|
- status
|
||||||
|
- roles/groups
|
||||||
|
- plan/limits
|
||||||
|
- API keys
|
||||||
|
- created
|
||||||
|
- last login / last API use
|
||||||
|
- storage used
|
||||||
|
- boxes created
|
||||||
|
|
||||||
|
Recommended account detail page:
|
||||||
|
|
||||||
|
- profile
|
||||||
|
- status controls
|
||||||
|
- password reset / force password change
|
||||||
|
- roles/groups assignment
|
||||||
|
- plan/limits assignment
|
||||||
|
- effective permissions preview
|
||||||
|
- API keys tab
|
||||||
|
- recent activity tab
|
||||||
|
- boxes owned by user
|
||||||
|
|
||||||
|
Recommended safe actions:
|
||||||
|
|
||||||
|
- disable user
|
||||||
|
- revoke all sessions
|
||||||
|
- revoke all API keys
|
||||||
|
- reset password
|
||||||
|
- assign role/group
|
||||||
|
- assign plan
|
||||||
|
- archive user
|
||||||
|
|
||||||
|
Recommended UX details:
|
||||||
|
|
||||||
|
- show effective permission summary before save
|
||||||
|
- warn when removing own admin access
|
||||||
|
- require confirmation for disabling final admin
|
||||||
|
- prevent accidental lockout at backend level
|
||||||
|
- show inherited vs direct settings clearly
|
||||||
|
|
||||||
|
## Proposed API Key System
|
||||||
|
|
||||||
|
Data model idea:
|
||||||
|
|
||||||
|
- API key id
|
||||||
|
- user id
|
||||||
|
- name/label
|
||||||
|
- hashed secret
|
||||||
|
- secret prefix for display
|
||||||
|
- scopes
|
||||||
|
- created at
|
||||||
|
- expires at
|
||||||
|
- last used at
|
||||||
|
- revoked at
|
||||||
|
- created by
|
||||||
|
- optional allowed IP/CIDR list
|
||||||
|
|
||||||
|
Security rules:
|
||||||
|
|
||||||
|
- show raw key only once on creation
|
||||||
|
- store only hash server-side
|
||||||
|
- allow revoke, rotate, rename
|
||||||
|
- support expiry dates
|
||||||
|
- log last-used timestamp
|
||||||
|
- rate limit failed key attempts
|
||||||
|
- avoid putting API keys in URLs
|
||||||
|
|
||||||
|
User-facing API key page:
|
||||||
|
|
||||||
|
- create key
|
||||||
|
- name key
|
||||||
|
- choose expiry
|
||||||
|
- view active/revoked keys
|
||||||
|
- revoke key
|
||||||
|
- copy cURL example
|
||||||
|
- see last used time
|
||||||
|
|
||||||
|
Admin-facing API key controls:
|
||||||
|
|
||||||
|
- view user key count and last use
|
||||||
|
- revoke a user key
|
||||||
|
- revoke all keys for disabled user
|
||||||
|
- optionally create key on behalf of user
|
||||||
|
- audit key creation/revocation
|
||||||
|
|
||||||
|
Permission behavior:
|
||||||
|
|
||||||
|
- bearer key resolves to user
|
||||||
|
- user roles/groups/plans determine upload policy
|
||||||
|
- key scopes can further restrict user permission but not exceed it
|
||||||
|
- API key can enable higher quota only if assigned user's plan allows it
|
||||||
|
|
||||||
|
Initial scopes:
|
||||||
|
|
||||||
|
- `box:create`
|
||||||
|
- `box:upload`
|
||||||
|
- `box:read`
|
||||||
|
- `box:download`
|
||||||
|
- `box:delete-own`
|
||||||
|
|
||||||
|
## Proposed Role/Group System
|
||||||
|
|
||||||
|
Replace tags with explicit authorization objects.
|
||||||
|
|
||||||
|
Recommended models:
|
||||||
|
|
||||||
|
- Role: permission bundle, e.g. `admin`, `operator`, `uploader`, `viewer`
|
||||||
|
- Group: collection of users with assigned roles and optional plan
|
||||||
|
- Plan: quota/limit policy, e.g. `guest`, `standard`, `trusted`, `unlimited`
|
||||||
|
|
||||||
|
Roles should answer:
|
||||||
|
|
||||||
|
- what can this user do?
|
||||||
|
|
||||||
|
Plans should answer:
|
||||||
|
|
||||||
|
- how much can this user use?
|
||||||
|
|
||||||
|
Groups should answer:
|
||||||
|
|
||||||
|
- who receives these defaults together?
|
||||||
|
|
||||||
|
Recommended permissions:
|
||||||
|
|
||||||
|
- admin.access
|
||||||
|
- admin.dashboard.view
|
||||||
|
- admin.users.view
|
||||||
|
- admin.users.manage
|
||||||
|
- admin.roles.manage
|
||||||
|
- admin.settings.view
|
||||||
|
- admin.settings.manage
|
||||||
|
- admin.boxes.view
|
||||||
|
- admin.boxes.manage
|
||||||
|
- boxes.create
|
||||||
|
- boxes.download.zip
|
||||||
|
- boxes.download.one_time
|
||||||
|
- boxes.password.set
|
||||||
|
- boxes.renew
|
||||||
|
- api_keys.manage_own
|
||||||
|
- api_keys.manage_any
|
||||||
|
|
||||||
|
Recommended limit fields:
|
||||||
|
|
||||||
|
- max file size
|
||||||
|
- max box size
|
||||||
|
- max boxes per day
|
||||||
|
- max storage active at once
|
||||||
|
- max expiry
|
||||||
|
- allowed expiry choices
|
||||||
|
- max API keys
|
||||||
|
- API key max TTL
|
||||||
|
- guest upload allowed
|
||||||
|
- ZIP allowed
|
||||||
|
- one-time allowed
|
||||||
|
- renew allowed
|
||||||
|
|
||||||
|
Recommended resolver:
|
||||||
|
|
||||||
|
- start with system defaults
|
||||||
|
- apply assigned plan quotas
|
||||||
|
- apply group plan when user has no direct plan
|
||||||
|
- apply direct user overrides last
|
||||||
|
- roles grant permissions
|
||||||
|
- scopes restrict API key actions
|
||||||
|
- hard global limits cap everything
|
||||||
|
|
||||||
|
Migration strategy:
|
||||||
|
|
||||||
|
- create role equivalents from current tag admin booleans
|
||||||
|
- create plan equivalents from current tag upload limits
|
||||||
|
- assign users based on existing `TagIDs`
|
||||||
|
- keep read-only legacy tag view during migration
|
||||||
|
- remove tag creation from final UI
|
||||||
|
|
||||||
|
## Extra Feature Ideas
|
||||||
|
|
||||||
|
Storage and cleanup:
|
||||||
|
|
||||||
|
- expired box cleanup page
|
||||||
|
- bulk delete expired boxes
|
||||||
|
- storage by age chart
|
||||||
|
- largest boxes list
|
||||||
|
- orphaned manifest/file scanner
|
||||||
|
- thumbnail cleanup/rebuild tools
|
||||||
|
|
||||||
|
Security:
|
||||||
|
|
||||||
|
- audit log
|
||||||
|
- active admin sessions page
|
||||||
|
- revoke sessions
|
||||||
|
- failed login tracking
|
||||||
|
- optional two-factor auth for admins
|
||||||
|
- final-admin protection
|
||||||
|
- configurable password policy
|
||||||
|
|
||||||
|
Operations:
|
||||||
|
|
||||||
|
- system health page
|
||||||
|
- config export
|
||||||
|
- backup/restore notes for data directory and BadgerDB
|
||||||
|
- maintenance mode
|
||||||
|
- manual thumbnail worker run
|
||||||
|
- background job status
|
||||||
|
|
||||||
|
User experience:
|
||||||
|
|
||||||
|
- account self-service page
|
||||||
|
- personal upload history
|
||||||
|
- personal quota meter
|
||||||
|
- personal API keys
|
||||||
|
- upload presets
|
||||||
|
- saved retention preferences
|
||||||
|
|
||||||
|
Admin UX:
|
||||||
|
|
||||||
|
- global search
|
||||||
|
- command palette
|
||||||
|
- filters saved per admin
|
||||||
|
- CSV export for users/boxes
|
||||||
|
- inline detail drawer for boxes/users
|
||||||
|
- change preview before saving roles/plans
|
||||||
|
|
||||||
|
Product:
|
||||||
|
|
||||||
|
- named boxes supported server-side
|
||||||
|
- custom slug support server-side
|
||||||
|
- private/listed boxes if public listing is added
|
||||||
|
- max view count server-side
|
||||||
|
- ownership model: boxes created by user/API key belong to that user
|
||||||
|
- public share page controls based on owner plan
|
||||||
|
|
||||||
|
## Suggested Implementation Phases
|
||||||
|
|
||||||
|
Phase 1: make current admin useful
|
||||||
|
|
||||||
|
- add real dashboard statistics
|
||||||
|
- add search/filter/pagination to boxes and users
|
||||||
|
- expose effective permissions on user rows/details
|
||||||
|
- add user edit form
|
||||||
|
- add storage cleanup actions
|
||||||
|
|
||||||
|
Phase 2: implement API keys
|
||||||
|
|
||||||
|
- add API key model in BadgerDB
|
||||||
|
- add create/revoke/list routes
|
||||||
|
- hash keys server-side
|
||||||
|
- validate bearer keys on API endpoints
|
||||||
|
- connect key to user permissions
|
||||||
|
- add self-service API key UI
|
||||||
|
|
||||||
|
Phase 3: replace tags
|
||||||
|
|
||||||
|
- add roles/plans/groups models
|
||||||
|
- add resolver
|
||||||
|
- add migration from tags
|
||||||
|
- update user management UI
|
||||||
|
- deprecate tag creation
|
||||||
|
|
||||||
|
Phase 4: polish into full admin solution
|
||||||
|
|
||||||
|
- audit log
|
||||||
|
- account detail pages
|
||||||
|
- system health
|
||||||
|
- advanced cleanup
|
||||||
|
- activity timeline
|
||||||
|
- safer setting editor
|
||||||
|
|
||||||
|
## Product Principle For The Overhaul
|
||||||
|
|
||||||
|
Keep WarpBox small, local, and understandable. The admin area should not become enterprise software cosplay. It should give the operator sharp tools:
|
||||||
|
|
||||||
|
- see what is happening
|
||||||
|
- fix common problems
|
||||||
|
- manage people safely
|
||||||
|
- give trusted users more power
|
||||||
|
- keep storage under control
|
||||||
|
- make permission decisions obvious
|
||||||
|
|
||||||
|
Best version: a retro control panel that behaves like a modern, careful admin console.
|
||||||
19
check.sh
Executable file
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 ./... "$@"
|
||||||
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[:])
|
||||||
|
}
|
||||||
@@ -1,759 +0,0 @@
|
|||||||
package boxstore
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"warpbox/lib/helpers"
|
|
||||||
"warpbox/lib/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
manifestFile = ".warpbox.json"
|
|
||||||
|
|
||||||
OneTimeDownloadRetentionKey = "one-time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
uploadRoot = filepath.Join("data", "uploads")
|
|
||||||
oneTimeDownloadExpiry int64
|
|
||||||
manifestMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
var retentionOptions = []models.RetentionOption{
|
|
||||||
{Key: "10s", Label: "10 seconds", Seconds: 10},
|
|
||||||
{Key: "10m", Label: "10 minutes", Seconds: 10 * 60},
|
|
||||||
{Key: "1h", Label: "1 hour", Seconds: 60 * 60},
|
|
||||||
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
|
|
||||||
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
|
|
||||||
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
|
|
||||||
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBoxID() (string, error) {
|
|
||||||
return helpers.RandomHexID(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidBoxID(boxID string) bool {
|
|
||||||
return helpers.ValidLowerHexID(boxID, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RetentionOptions() []models.RetentionOption {
|
|
||||||
options := make([]models.RetentionOption, len(retentionOptions))
|
|
||||||
copy(options, retentionOptions)
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultRetentionOption() models.RetentionOption {
|
|
||||||
return retentionOptions[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetUploadRoot(path string) {
|
|
||||||
if path == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uploadRoot = filepath.Clean(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetOneTimeDownloadExpiry(seconds int64) {
|
|
||||||
oneTimeDownloadExpiry = seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func OneTimeDownloadExpiry() int64 {
|
|
||||||
return oneTimeDownloadExpiry
|
|
||||||
}
|
|
||||||
|
|
||||||
func UploadRoot() string {
|
|
||||||
return uploadRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
func BoxPath(boxID string) string {
|
|
||||||
return filepath.Join(uploadRoot, boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeBoxPath(boxID string) (string, bool) {
|
|
||||||
if !ValidBoxID(boxID) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return helpers.SafeChildPath(uploadRoot, boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ManifestPath(boxID string) string {
|
|
||||||
return filepath.Join(BoxPath(boxID), manifestFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func SafeBoxFilePath(boxID string, filename string) (string, bool) {
|
|
||||||
boxPath, ok := safeBoxPath(boxID)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return helpers.SafeChildPath(boxPath, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsSafeRegularBoxFile(boxID string, filename string) bool {
|
|
||||||
path, ok := SafeBoxFilePath(boxID, filename)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ensureRegularFile(path) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DeleteBox(boxID string) error {
|
|
||||||
boxPath, ok := safeBoxPath(boxID)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Invalid box id")
|
|
||||||
}
|
|
||||||
return os.RemoveAll(boxPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListBoxSummaries() ([]models.BoxSummary, error) {
|
|
||||||
entries, err := os.ReadDir(uploadRoot)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries := make([]models.BoxSummary, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
summary, err := BoxSummary(entry.Name())
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
summaries = append(summaries, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(summaries, func(i int, j int) bool {
|
|
||||||
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
|
|
||||||
})
|
|
||||||
return summaries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BoxSummary(boxID string) (models.BoxSummary, error) {
|
|
||||||
files, err := ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return models.BoxSummary{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifest models.BoxManifest
|
|
||||||
hasManifest := false
|
|
||||||
if readManifest, err := ReadManifest(boxID); err == nil {
|
|
||||||
manifest = readManifest
|
|
||||||
hasManifest = true
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.Size
|
|
||||||
}
|
|
||||||
|
|
||||||
summary := models.BoxSummary{
|
|
||||||
ID: boxID,
|
|
||||||
FileCount: len(files),
|
|
||||||
TotalSize: totalSize,
|
|
||||||
TotalSizeLabel: helpers.FormatBytes(totalSize),
|
|
||||||
}
|
|
||||||
if hasManifest {
|
|
||||||
summary.CreatedAt = manifest.CreatedAt
|
|
||||||
summary.ExpiresAt = manifest.ExpiresAt
|
|
||||||
summary.Expired = IsExpired(manifest)
|
|
||||||
summary.OneTimeDownload = manifest.OneTimeDownload
|
|
||||||
summary.PasswordProtected = IsPasswordProtected(manifest)
|
|
||||||
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
|
|
||||||
summary.CreatedAt = info.ModTime().UTC()
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListFiles(boxID string) ([]models.BoxFile, error) {
|
|
||||||
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
|
||||||
return DecorateFiles(boxID, manifest.Files), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return listCompletedFilesFromDisk(boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
|
|
||||||
retention := normalizeRetentionOption(request.RetentionKey)
|
|
||||||
usedNames := make(map[string]int, len(request.Files))
|
|
||||||
files := make([]models.BoxFile, 0, len(request.Files))
|
|
||||||
|
|
||||||
for _, fileRequest := range request.Files {
|
|
||||||
filename, ok := helpers.SafeFilename(fileRequest.Name)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename = helpers.UniqueNameInBatch(filename, usedNames)
|
|
||||||
fileID, err := helpers.RandomHexID(8)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not create file id")
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
|
|
||||||
if mimeType == "" {
|
|
||||||
mimeType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, models.BoxFile{
|
|
||||||
ID: fileID,
|
|
||||||
Name: filename,
|
|
||||||
Size: fileRequest.Size,
|
|
||||||
MimeType: mimeType,
|
|
||||||
Status: models.FileStatusWait,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
disableZip := false
|
|
||||||
if request.AllowZip != nil {
|
|
||||||
disableZip = !*request.AllowZip
|
|
||||||
}
|
|
||||||
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
|
|
||||||
if oneTimeDownload {
|
|
||||||
disableZip = false
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest := models.BoxManifest{
|
|
||||||
Files: files,
|
|
||||||
CreatedAt: now,
|
|
||||||
RetentionKey: retention.Key,
|
|
||||||
RetentionLabel: retention.Label,
|
|
||||||
RetentionSecs: retention.Seconds,
|
|
||||||
DisableZip: disableZip,
|
|
||||||
OneTimeDownload: oneTimeDownload,
|
|
||||||
}
|
|
||||||
|
|
||||||
if password := strings.TrimSpace(request.Password); password != "" {
|
|
||||||
authToken, err := helpers.RandomHexID(16)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not secure upload box")
|
|
||||||
}
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not secure upload box")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.PasswordHash = string(passwordHash)
|
|
||||||
manifest.PasswordHashAlg = "bcrypt"
|
|
||||||
manifest.AuthToken = authToken
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := WriteManifest(boxID, manifest); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
decoratedFiles := make([]models.BoxFile, 0, len(files))
|
|
||||||
for _, file := range files {
|
|
||||||
decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoratedFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsExpired(manifest models.BoxManifest) bool {
|
|
||||||
return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsPasswordProtected(manifest models.BoxManifest) bool {
|
|
||||||
return manifest.PasswordHash != "" && manifest.AuthToken != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func VerifyPassword(manifest models.BoxManifest, password string) bool {
|
|
||||||
if !IsPasswordProtected(manifest) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := manifest.PasswordHash
|
|
||||||
if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
|
|
||||||
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
actual := legacyPasswordHash(manifest.PasswordSalt, password)
|
|
||||||
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func VerifyAuthToken(manifest models.BoxManifest, token string) bool {
|
|
||||||
if !IsPasswordProtected(manifest) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if token == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) {
|
|
||||||
if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Invalid file status")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return models.BoxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for index, file := range manifest.Files {
|
|
||||||
if file.ID != fileID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Files[index].Status = status
|
|
||||||
startRetentionIfTerminalUnlocked(&manifest)
|
|
||||||
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
|
||||||
return models.BoxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return DecorateFile(boxID, manifest.Files[index]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return models.BoxFile{}, fmt.Errorf("File not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadManifest(boxID string) (models.BoxManifest, error) {
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
return readManifestUnlocked(boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteManifest(boxID string, manifest models.BoxManifest) error {
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
return writeManifestUnlocked(boxID, manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
|
||||||
return manifest, writeManifestUnlocked(boxID, manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
|
||||||
path, ok := SafeBoxFilePath(boxID, filename)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Invalid file")
|
|
||||||
}
|
|
||||||
if err := ensureRegularFile(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
zipName, ok := safeZipEntryName(filename)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Invalid zip entry")
|
|
||||||
}
|
|
||||||
|
|
||||||
source, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
destination, err := zipWriter.Create(zipName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(destination, source)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) {
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return models.BoxFile{}, err
|
|
||||||
}
|
|
||||||
if IsExpired(manifest) {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Box expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIndex := -1
|
|
||||||
for index, manifestFile := range manifest.Files {
|
|
||||||
if manifestFile.ID == fileID {
|
|
||||||
fileIndex = index
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileIndex < 0 {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("File not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := manifest.Files[fileIndex].Name
|
|
||||||
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
|
|
||||||
}
|
|
||||||
|
|
||||||
destination, ok := SafeBoxFilePath(boxID, filename)
|
|
||||||
if !ok {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
if err := saveMultipartFile(file, destination); err != nil {
|
|
||||||
manifest.Files[fileIndex].Status = models.FileStatusFailed
|
|
||||||
startRetentionIfTerminalUnlocked(&manifest)
|
|
||||||
writeManifestUnlocked(boxID, manifest)
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Files[fileIndex].Size = file.Size
|
|
||||||
manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename)
|
|
||||||
manifest.Files[fileIndex].Status = models.FileStatusReady
|
|
||||||
startRetentionIfTerminalUnlocked(&manifest)
|
|
||||||
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
|
||||||
return models.BoxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return DecorateFile(boxID, manifest.Files[fileIndex]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error) {
|
|
||||||
filename, ok := helpers.SafeFilename(file.Filename)
|
|
||||||
if !ok {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
boxPath := BoxPath(boxID)
|
|
||||||
if err := os.MkdirAll(boxPath, 0755); err != nil {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename = helpers.UniqueFilename(boxPath, filename)
|
|
||||||
destination, ok := SafeBoxFilePath(boxID, filename)
|
|
||||||
if !ok {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
if err := saveMultipartFile(file, destination); err != nil {
|
|
||||||
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return DecorateFile(boxID, models.BoxFile{
|
|
||||||
ID: filename,
|
|
||||||
Name: filename,
|
|
||||||
Size: file.Size,
|
|
||||||
MimeType: helpers.MimeTypeForFile(destination, filename),
|
|
||||||
Status: models.FileStatusReady,
|
|
||||||
}), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
|
|
||||||
if file.MimeType == "" {
|
|
||||||
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
|
|
||||||
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.SizeLabel == "" {
|
|
||||||
file.SizeLabel = helpers.FormatBytes(file.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
file.IconPath = IconForMimeType(file.MimeType, file.Name)
|
|
||||||
if file.ThumbnailPath != nil {
|
|
||||||
file.ThumbnailURL = *file.ThumbnailPath
|
|
||||||
}
|
|
||||||
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
|
||||||
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
|
||||||
file.IsComplete = file.Status == models.FileStatusReady
|
|
||||||
|
|
||||||
switch file.Status {
|
|
||||||
case models.FileStatusReady:
|
|
||||||
file.StatusLabel = "Ready"
|
|
||||||
file.Title = "Download " + file.Name
|
|
||||||
case models.FileStatusFailed:
|
|
||||||
file.StatusLabel = "Failed"
|
|
||||||
file.Title = "Failed to upload"
|
|
||||||
case models.FileStatusWork:
|
|
||||||
file.StatusLabel = "Loading"
|
|
||||||
file.Title = "Loading"
|
|
||||||
default:
|
|
||||||
file.Status = models.FileStatusWait
|
|
||||||
file.StatusLabel = "Waiting"
|
|
||||||
file.Title = "Loading"
|
|
||||||
}
|
|
||||||
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
func DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
|
|
||||||
decorated := make([]models.BoxFile, 0, len(files))
|
|
||||||
for _, file := range files {
|
|
||||||
decorated = append(decorated, DecorateFile(boxID, file))
|
|
||||||
}
|
|
||||||
return decorated
|
|
||||||
}
|
|
||||||
|
|
||||||
func IconForMimeType(mimeType string, filename string) string {
|
|
||||||
extension := strings.ToLower(filepath.Ext(filename))
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case extension == ".exe":
|
|
||||||
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
|
|
||||||
case strings.HasPrefix(mimeType, "image/"):
|
|
||||||
return "/static/img/sprites/bitmap.png"
|
|
||||||
case strings.HasPrefix(mimeType, "video/"):
|
|
||||||
return "/static/img/icons/netshow_notransm-1.png"
|
|
||||||
case strings.HasPrefix(mimeType, "audio/"):
|
|
||||||
return "/static/img/icons/netshow_notransm-1.png"
|
|
||||||
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
|
|
||||||
return "/static/img/sprites/notepad_file-1.png"
|
|
||||||
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
|
|
||||||
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
|
|
||||||
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
|
|
||||||
return "/static/img/sprites/font.png"
|
|
||||||
case extension == ".pdf":
|
|
||||||
return "/static/img/sprites/journal.png"
|
|
||||||
case extension == ".html" || extension == ".css" || extension == ".js":
|
|
||||||
return "/static/img/sprites/frame_web-0.png"
|
|
||||||
default:
|
|
||||||
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
|
||||||
manifestMu.Lock()
|
|
||||||
defer manifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
changed := false
|
|
||||||
for index, file := range manifest.Files {
|
|
||||||
path, ok := SafeBoxFilePath(boxID, file.Name)
|
|
||||||
if !ok || ensureRegularFile(path) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil || !info.Mode().IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.Status == models.FileStatusReady && file.Size == info.Size() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The manifest is the UI source of truth, but disk wins when an upload
|
|
||||||
// was saved and the final status write/response was interrupted.
|
|
||||||
manifest.Files[index].Size = info.Size()
|
|
||||||
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
|
|
||||||
manifest.Files[index].Status = models.FileStatusReady
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
startRetentionIfTerminalUnlocked(&manifest)
|
|
||||||
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
|
|
||||||
entries, err := os.ReadDir(BoxPath(boxID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
files := make([]models.BoxFile, 0, len(entries))
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || entry.Name() == manifestFile || entry.Type()&os.ModeSymlink != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := entry.Info()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := entry.Name()
|
|
||||||
files = append(files, DecorateFile(boxID, models.BoxFile{
|
|
||||||
ID: name,
|
|
||||||
Name: name,
|
|
||||||
Size: info.Size(),
|
|
||||||
MimeType: helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), name), name),
|
|
||||||
Status: models.FileStatusReady,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
|
|
||||||
var manifest models.BoxManifest
|
|
||||||
data, err := os.ReadFile(ManifestPath(boxID))
|
|
||||||
if err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRetentionOption(key string) models.RetentionOption {
|
|
||||||
for _, option := range retentionOptions {
|
|
||||||
if option.Key == key {
|
|
||||||
return option
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DefaultRetentionOption()
|
|
||||||
}
|
|
||||||
|
|
||||||
func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
|
|
||||||
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
seconds := manifest.RetentionSecs
|
|
||||||
if manifest.OneTimeDownload {
|
|
||||||
seconds = oneTimeDownloadExpiry
|
|
||||||
} else if seconds <= 0 {
|
|
||||||
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
if seconds <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range manifest.Files {
|
|
||||||
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retention starts after uploads settle so slow or very large uploads do
|
|
||||||
// not expire before users get a real chance to open the box.
|
|
||||||
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func legacyPasswordHash(salt string, password string) string {
|
|
||||||
sum := sha256.Sum256([]byte(salt + ":" + password))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manifest writes are serialized because the browser can upload several files
|
|
||||||
// concurrently into the same box. Without this lock, status updates can race.
|
|
||||||
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
|
|
||||||
data, err := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(ManifestPath(boxID), data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveMultipartFile(file *multipart.FileHeader, destination string) error {
|
|
||||||
source, err := file.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
target, tempPath, err := createTempSibling(destination)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
target.Close()
|
|
||||||
if !committed {
|
|
||||||
os.Remove(tempPath)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := io.Copy(target, source); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := target.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.Rename(tempPath, destination); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTempSibling(destination string) (*os.File, string, error) {
|
|
||||||
directory := filepath.Dir(destination)
|
|
||||||
if err := os.MkdirAll(directory, 0755); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return target, target.Name(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeZipEntryName(filename string) (string, bool) {
|
|
||||||
filename = strings.TrimSpace(filename)
|
|
||||||
if filename == "" || filepath.IsAbs(filename) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned := filepath.ToSlash(filepath.Clean(filename))
|
|
||||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return cleaned, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureRegularFile(path string) error {
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("Invalid file")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
74
lib/boxstore/summary.go
Normal file
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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Source string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SourceDefault Source = "default"
|
|
||||||
SourceEnv Source = "environment"
|
|
||||||
SourceDB Source = "db override"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AdminEnabledMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AdminEnabledAuto AdminEnabledMode = "auto"
|
|
||||||
AdminEnabledTrue AdminEnabledMode = "true"
|
|
||||||
AdminEnabledFalse AdminEnabledMode = "false"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
|
||||||
SettingAPIEnabled = "api_enabled"
|
|
||||||
SettingZipDownloadsEnabled = "zip_downloads_enabled"
|
|
||||||
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
|
|
||||||
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
|
|
||||||
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
|
|
||||||
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
|
|
||||||
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
|
||||||
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
|
||||||
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
|
||||||
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
|
||||||
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
|
||||||
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
|
||||||
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
|
||||||
SettingSessionTTLSeconds = "session_ttl_seconds"
|
|
||||||
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
|
||||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
|
||||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
|
||||||
SettingDataDir = "data_dir"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SettingType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SettingTypeBool SettingType = "bool"
|
|
||||||
SettingTypeInt64 SettingType = "int64"
|
|
||||||
SettingTypeInt SettingType = "int"
|
|
||||||
SettingTypeText SettingType = "text"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SettingDefinition struct {
|
|
||||||
Key string
|
|
||||||
EnvName string
|
|
||||||
Label string
|
|
||||||
Type SettingType
|
|
||||||
Editable bool
|
|
||||||
HardLimit bool
|
|
||||||
Minimum int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingRow struct {
|
|
||||||
Definition SettingDefinition
|
|
||||||
Value string
|
|
||||||
Source Source
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
DataDir string
|
|
||||||
UploadsDir string
|
|
||||||
DBDir string
|
|
||||||
|
|
||||||
AdminPassword string
|
|
||||||
AdminUsername string
|
|
||||||
AdminEmail string
|
|
||||||
AdminEnabled AdminEnabledMode
|
|
||||||
AdminCookieSecure bool
|
|
||||||
AllowAdminSettingsOverride bool
|
|
||||||
|
|
||||||
GuestUploadsEnabled bool
|
|
||||||
APIEnabled bool
|
|
||||||
ZipDownloadsEnabled bool
|
|
||||||
OneTimeDownloadsEnabled bool
|
|
||||||
OneTimeDownloadExpirySeconds int64
|
|
||||||
OneTimeDownloadRetryOnFailure bool
|
|
||||||
RenewOnAccessEnabled bool
|
|
||||||
RenewOnDownloadEnabled bool
|
|
||||||
|
|
||||||
DefaultGuestExpirySeconds int64
|
|
||||||
MaxGuestExpirySeconds int64
|
|
||||||
GlobalMaxFileSizeBytes int64
|
|
||||||
GlobalMaxBoxSizeBytes int64
|
|
||||||
DefaultUserMaxFileSizeBytes int64
|
|
||||||
DefaultUserMaxBoxSizeBytes int64
|
|
||||||
SessionTTLSeconds int64
|
|
||||||
BoxPollIntervalMS int
|
|
||||||
ThumbnailBatchSize int
|
|
||||||
ThumbnailIntervalSeconds int
|
|
||||||
|
|
||||||
sources map[string]Source
|
|
||||||
values map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
var Definitions = []SettingDefinition{
|
|
||||||
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
|
||||||
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
|
||||||
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
|
|
||||||
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
|
||||||
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
|
||||||
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
|
||||||
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
|
||||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
|
||||||
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
|
||||||
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
|
||||||
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
|
||||||
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
|
||||||
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
|
||||||
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
cfg := &Config{
|
|
||||||
DataDir: "./data",
|
|
||||||
AdminUsername: "admin",
|
|
||||||
AdminEnabled: AdminEnabledAuto,
|
|
||||||
AllowAdminSettingsOverride: true,
|
|
||||||
GuestUploadsEnabled: true,
|
|
||||||
APIEnabled: true,
|
|
||||||
ZipDownloadsEnabled: true,
|
|
||||||
OneTimeDownloadsEnabled: true,
|
|
||||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
|
||||||
OneTimeDownloadRetryOnFailure: false,
|
|
||||||
DefaultGuestExpirySeconds: 10,
|
|
||||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
|
||||||
SessionTTLSeconds: 24 * 60 * 60,
|
|
||||||
BoxPollIntervalMS: 5000,
|
|
||||||
ThumbnailBatchSize: 10,
|
|
||||||
ThumbnailIntervalSeconds: 30,
|
|
||||||
sources: make(map[string]Source),
|
|
||||||
values: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.captureDefaults()
|
|
||||||
|
|
||||||
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
|
||||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
|
||||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
|
||||||
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
|
|
||||||
}
|
|
||||||
cfg.AdminEnabled = mode
|
|
||||||
}
|
|
||||||
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
envBools := []struct {
|
|
||||||
key string
|
|
||||||
name string
|
|
||||||
target *bool
|
|
||||||
}{
|
|
||||||
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
|
|
||||||
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
|
|
||||||
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
|
|
||||||
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
|
|
||||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
|
||||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
|
||||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
|
||||||
}
|
|
||||||
for _, item := range envBools {
|
|
||||||
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
envInt64s := []struct {
|
|
||||||
key string
|
|
||||||
name string
|
|
||||||
min int64
|
|
||||||
target *int64
|
|
||||||
}{
|
|
||||||
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
|
|
||||||
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
|
||||||
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
|
||||||
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
|
||||||
}
|
|
||||||
for _, item := range envInt64s {
|
|
||||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sizeEnvVars := []struct {
|
|
||||||
key string
|
|
||||||
mbName string
|
|
||||||
bytesName string
|
|
||||||
target *int64
|
|
||||||
}{
|
|
||||||
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
|
|
||||||
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
|
|
||||||
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
|
|
||||||
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
|
||||||
}
|
|
||||||
for _, item := range sizeEnvVars {
|
|
||||||
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
envInts := []struct {
|
|
||||||
key string
|
|
||||||
name string
|
|
||||||
min int
|
|
||||||
target *int
|
|
||||||
}{
|
|
||||||
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
|
||||||
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
|
||||||
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
|
||||||
}
|
|
||||||
for _, item := range envInts {
|
|
||||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.DataDir = filepath.Clean(cfg.DataDir)
|
|
||||||
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
|
|
||||||
cfg.DataDir = "data"
|
|
||||||
}
|
|
||||||
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
|
|
||||||
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
|
||||||
}
|
|
||||||
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
|
||||||
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
|
||||||
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
|
||||||
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) EnsureDirectories() error {
|
|
||||||
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
|
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
|
||||||
return fmt.Errorf("create %s: %w", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
|
||||||
if !cfg.AllowAdminSettingsOverride {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for key, value := range overrides {
|
|
||||||
if err := cfg.ApplyOverride(key, value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) ApplyOverride(key string, value string) error {
|
|
||||||
def, ok := Definition(key)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unknown setting %q", key)
|
|
||||||
}
|
|
||||||
if !def.Editable || def.HardLimit {
|
|
||||||
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch def.Type {
|
|
||||||
case SettingTypeBool:
|
|
||||||
parsed, err := parseBool(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
|
||||||
}
|
|
||||||
cfg.assignBool(key, parsed, SourceDB)
|
|
||||||
case SettingTypeInt64:
|
|
||||||
parsed, err := parseInt64(value, def.Minimum)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
|
||||||
}
|
|
||||||
cfg.assignInt64(key, parsed, SourceDB)
|
|
||||||
case SettingTypeInt:
|
|
||||||
parsed64, err := parseInt64(value, def.Minimum)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
|
||||||
}
|
|
||||||
cfg.assignInt(key, int(parsed64), SourceDB)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("setting %q is not runtime editable", key)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) SettingRows() []SettingRow {
|
|
||||||
rows := make([]SettingRow, 0, len(Definitions))
|
|
||||||
for _, def := range Definitions {
|
|
||||||
rows = append(rows, SettingRow{
|
|
||||||
Definition: def,
|
|
||||||
Value: cfg.values[def.Key],
|
|
||||||
Source: cfg.sourceFor(def.Key),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) Source(key string) Source {
|
|
||||||
return cfg.sourceFor(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
|
||||||
switch cfg.AdminEnabled {
|
|
||||||
case AdminEnabledFalse:
|
|
||||||
return false
|
|
||||||
case AdminEnabledTrue:
|
|
||||||
return hasAdminUser
|
|
||||||
default:
|
|
||||||
return hasAdminUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Definition(key string) (SettingDefinition, bool) {
|
|
||||||
for _, def := range Definitions {
|
|
||||||
if def.Key == key {
|
|
||||||
return def, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SettingDefinition{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func EditableDefinitions() []SettingDefinition {
|
|
||||||
defs := make([]SettingDefinition, 0, len(Definitions))
|
|
||||||
for _, def := range Definitions {
|
|
||||||
if def.Editable && !def.HardLimit {
|
|
||||||
defs = append(defs, def)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) captureDefaults() {
|
|
||||||
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
|
||||||
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
|
|
||||||
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
|
||||||
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
|
||||||
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
|
||||||
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
|
||||||
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
|
||||||
raw := os.Getenv(name)
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
*target = raw
|
|
||||||
if key != "" {
|
|
||||||
cfg.setValue(key, raw, SourceEnv)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
|
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parsed, err := parseBool(raw)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", name, err)
|
|
||||||
}
|
|
||||||
*target = parsed
|
|
||||||
if key != "" {
|
|
||||||
cfg.setValue(key, formatBool(parsed), SourceEnv)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
|
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parsed, err := parseInt64(raw, min)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", name, err)
|
|
||||||
}
|
|
||||||
*target = parsed
|
|
||||||
if key != "" {
|
|
||||||
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
|
|
||||||
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
|
|
||||||
parsed, err := parseInt64(rawBytes, min)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", bytesName, err)
|
|
||||||
}
|
|
||||||
*target = parsed
|
|
||||||
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMB := strings.TrimSpace(os.Getenv(mbName))
|
|
||||||
if rawMB == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parsedMB, err := parseInt64(rawMB, min)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", mbName, err)
|
|
||||||
}
|
|
||||||
if parsedMB > math.MaxInt64/(1024*1024) {
|
|
||||||
return fmt.Errorf("%s: is too large", mbName)
|
|
||||||
}
|
|
||||||
parsedBytes := parsedMB * 1024 * 1024
|
|
||||||
*target = parsedBytes
|
|
||||||
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
|
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if raw == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
parsed, err := parseInt(raw, min)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", name, err)
|
|
||||||
}
|
|
||||||
*target = parsed
|
|
||||||
if key != "" {
|
|
||||||
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
|
||||||
switch key {
|
|
||||||
case SettingGuestUploadsEnabled:
|
|
||||||
cfg.GuestUploadsEnabled = value
|
|
||||||
case SettingAPIEnabled:
|
|
||||||
cfg.APIEnabled = value
|
|
||||||
case SettingZipDownloadsEnabled:
|
|
||||||
cfg.ZipDownloadsEnabled = value
|
|
||||||
case SettingOneTimeDownloadsEnabled:
|
|
||||||
cfg.OneTimeDownloadsEnabled = value
|
|
||||||
case SettingRenewOnAccessEnabled:
|
|
||||||
cfg.RenewOnAccessEnabled = value
|
|
||||||
case SettingRenewOnDownloadEnabled:
|
|
||||||
cfg.RenewOnDownloadEnabled = value
|
|
||||||
}
|
|
||||||
cfg.setValue(key, formatBool(value), source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
|
||||||
switch key {
|
|
||||||
case SettingDefaultGuestExpirySecs:
|
|
||||||
cfg.DefaultGuestExpirySeconds = value
|
|
||||||
case SettingMaxGuestExpirySecs:
|
|
||||||
cfg.MaxGuestExpirySeconds = value
|
|
||||||
case SettingOneTimeDownloadExpirySecs:
|
|
||||||
cfg.OneTimeDownloadExpirySeconds = value
|
|
||||||
case SettingDefaultUserMaxFileBytes:
|
|
||||||
cfg.DefaultUserMaxFileSizeBytes = value
|
|
||||||
case SettingDefaultUserMaxBoxBytes:
|
|
||||||
cfg.DefaultUserMaxBoxSizeBytes = value
|
|
||||||
case SettingSessionTTLSeconds:
|
|
||||||
cfg.SessionTTLSeconds = value
|
|
||||||
}
|
|
||||||
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) assignInt(key string, value int, source Source) {
|
|
||||||
switch key {
|
|
||||||
case SettingBoxPollIntervalMS:
|
|
||||||
cfg.BoxPollIntervalMS = value
|
|
||||||
case SettingThumbnailBatchSize:
|
|
||||||
cfg.ThumbnailBatchSize = value
|
|
||||||
case SettingThumbnailIntervalSeconds:
|
|
||||||
cfg.ThumbnailIntervalSeconds = value
|
|
||||||
}
|
|
||||||
cfg.setValue(key, strconv.Itoa(value), source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) setValue(key string, value string, source Source) {
|
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg.values[key] = value
|
|
||||||
cfg.sources[key] = source
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cfg *Config) sourceFor(key string) Source {
|
|
||||||
source, ok := cfg.sources[key]
|
|
||||||
if !ok {
|
|
||||||
return SourceDefault
|
|
||||||
}
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseBool(value string) (bool, error) {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "1", "t", "true", "y", "yes", "on":
|
|
||||||
return true, nil
|
|
||||||
case "0", "f", "false", "n", "no", "off":
|
|
||||||
return false, nil
|
|
||||||
default:
|
|
||||||
return false, fmt.Errorf("must be a boolean")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInt64(value string, min int64) (int64, error) {
|
|
||||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("must be an integer")
|
|
||||||
}
|
|
||||||
if parsed < min {
|
|
||||||
return 0, fmt.Errorf("must be at least %d", min)
|
|
||||||
}
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInt(value string, min int) (int, error) {
|
|
||||||
parsed64, err := parseInt64(value, int64(min))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if parsed64 > int64(^uint(0)>>1) {
|
|
||||||
return 0, fmt.Errorf("is too large")
|
|
||||||
}
|
|
||||||
return int(parsed64), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBool(value bool) string {
|
|
||||||
if value {
|
|
||||||
return "true"
|
|
||||||
}
|
|
||||||
return "false"
|
|
||||||
}
|
|
||||||
69
lib/config/definitions.go
Normal file
69
lib/config/definitions.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
var Definitions = []SettingDefinition{
|
||||||
|
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
||||||
|
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
|
||||||
|
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||||
|
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||||
|
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
|
rows := make([]SettingRow, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
rows = append(rows, SettingRow{
|
||||||
|
Definition: def,
|
||||||
|
Value: cfg.values[def.Key],
|
||||||
|
Source: cfg.sourceFor(def.Key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Source(key string) Source {
|
||||||
|
return cfg.sourceFor(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
||||||
|
switch cfg.AdminEnabled {
|
||||||
|
case AdminEnabledFalse:
|
||||||
|
return false
|
||||||
|
case AdminEnabledTrue:
|
||||||
|
return hasAdminUser
|
||||||
|
default:
|
||||||
|
return hasAdminUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Definition(key string) (SettingDefinition, bool) {
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return def, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SettingDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditableDefinitions() []SettingDefinition {
|
||||||
|
defs := make([]SettingDefinition, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Editable && !def.HardLimit {
|
||||||
|
defs = append(defs, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
262
lib/config/load.go
Normal file
262
lib/config/load.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
DataDir: "./data",
|
||||||
|
AdminUsername: "admin",
|
||||||
|
AdminEnabled: AdminEnabledAuto,
|
||||||
|
AllowAdminSettingsOverride: true,
|
||||||
|
GuestUploadsEnabled: true,
|
||||||
|
APIEnabled: true,
|
||||||
|
ZipDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||||
|
OneTimeDownloadRetryOnFailure: false,
|
||||||
|
DefaultGuestExpirySeconds: 10,
|
||||||
|
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||||
|
SessionTTLSeconds: 24 * 60 * 60,
|
||||||
|
BoxPollIntervalMS: 5000,
|
||||||
|
ThumbnailBatchSize: 10,
|
||||||
|
ThumbnailIntervalSeconds: 30,
|
||||||
|
sources: make(map[string]Source),
|
||||||
|
values: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config precedence: defaults -> env -> overrides.
|
||||||
|
// Overrides are applied after Load by the server once the metadata store opens.
|
||||||
|
cfg.captureDefaults()
|
||||||
|
|
||||||
|
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||||
|
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||||
|
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
|
||||||
|
}
|
||||||
|
cfg.AdminEnabled = mode
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envBools := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
target *bool
|
||||||
|
}{
|
||||||
|
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
|
||||||
|
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
|
||||||
|
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
|
||||||
|
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
|
||||||
|
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||||
|
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||||
|
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||||
|
}
|
||||||
|
for _, item := range envBools {
|
||||||
|
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInt64s := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int64
|
||||||
|
target *int64
|
||||||
|
}{
|
||||||
|
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
|
||||||
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
|
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||||
|
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInt64s {
|
||||||
|
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeEnvVars := []struct {
|
||||||
|
key string
|
||||||
|
mbName string
|
||||||
|
bytesName string
|
||||||
|
target *int64
|
||||||
|
}{
|
||||||
|
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
|
||||||
|
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
|
||||||
|
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
|
||||||
|
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
||||||
|
}
|
||||||
|
for _, item := range sizeEnvVars {
|
||||||
|
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInts := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int
|
||||||
|
target *int
|
||||||
|
}{
|
||||||
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
|
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInts {
|
||||||
|
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DataDir = filepath.Clean(cfg.DataDir)
|
||||||
|
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
|
||||||
|
cfg.DataDir = "data"
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||||
|
}
|
||||||
|
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||||
|
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||||
|
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||||
|
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) EnsureDirectories() error {
|
||||||
|
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (cfg *Config) captureDefaults() {
|
||||||
|
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
||||||
|
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
|
||||||
|
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
||||||
|
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
||||||
|
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*target = raw
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, raw, SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, formatBool(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt64(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
|
||||||
|
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
|
||||||
|
parsed, err := parseInt64(rawBytes, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", bytesName, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawMB := strings.TrimSpace(os.Getenv(mbName))
|
||||||
|
if rawMB == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsedMB, err := parseInt64(rawMB, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", mbName, err)
|
||||||
|
}
|
||||||
|
if parsedMB > math.MaxInt64/(1024*1024) {
|
||||||
|
return fmt.Errorf("%s: is too large", mbName)
|
||||||
|
}
|
||||||
|
parsedBytes := parsedMB * 1024 * 1024
|
||||||
|
*target = parsedBytes
|
||||||
|
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
100
lib/config/models.go
Normal file
100
lib/config/models.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceDefault Source = "default"
|
||||||
|
SourceEnv Source = "environment"
|
||||||
|
SourceDB Source = "db override"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminEnabledMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdminEnabledAuto AdminEnabledMode = "auto"
|
||||||
|
AdminEnabledTrue AdminEnabledMode = "true"
|
||||||
|
AdminEnabledFalse AdminEnabledMode = "false"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
||||||
|
SettingAPIEnabled = "api_enabled"
|
||||||
|
SettingZipDownloadsEnabled = "zip_downloads_enabled"
|
||||||
|
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
|
||||||
|
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
|
||||||
|
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
|
||||||
|
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
|
||||||
|
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||||
|
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||||
|
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||||
|
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
||||||
|
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
||||||
|
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
||||||
|
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
||||||
|
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||||
|
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||||
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
|
SettingDataDir = "data_dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingTypeBool SettingType = "bool"
|
||||||
|
SettingTypeInt64 SettingType = "int64"
|
||||||
|
SettingTypeInt SettingType = "int"
|
||||||
|
SettingTypeText SettingType = "text"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingDefinition struct {
|
||||||
|
Key string
|
||||||
|
EnvName string
|
||||||
|
Label string
|
||||||
|
Type SettingType
|
||||||
|
Editable bool
|
||||||
|
HardLimit bool
|
||||||
|
Minimum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingRow struct {
|
||||||
|
Definition SettingDefinition
|
||||||
|
Value string
|
||||||
|
Source Source
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
UploadsDir string
|
||||||
|
DBDir string
|
||||||
|
|
||||||
|
AdminPassword string
|
||||||
|
AdminUsername string
|
||||||
|
AdminEmail string
|
||||||
|
AdminEnabled AdminEnabledMode
|
||||||
|
AdminCookieSecure bool
|
||||||
|
AllowAdminSettingsOverride bool
|
||||||
|
|
||||||
|
GuestUploadsEnabled bool
|
||||||
|
APIEnabled bool
|
||||||
|
ZipDownloadsEnabled bool
|
||||||
|
OneTimeDownloadsEnabled bool
|
||||||
|
OneTimeDownloadExpirySeconds int64
|
||||||
|
OneTimeDownloadRetryOnFailure bool
|
||||||
|
RenewOnAccessEnabled bool
|
||||||
|
RenewOnDownloadEnabled bool
|
||||||
|
|
||||||
|
DefaultGuestExpirySeconds int64
|
||||||
|
MaxGuestExpirySeconds int64
|
||||||
|
GlobalMaxFileSizeBytes int64
|
||||||
|
GlobalMaxBoxSizeBytes int64
|
||||||
|
DefaultUserMaxFileSizeBytes int64
|
||||||
|
DefaultUserMaxBoxSizeBytes int64
|
||||||
|
SessionTTLSeconds int64
|
||||||
|
BoxPollIntervalMS int
|
||||||
|
ThumbnailBatchSize int
|
||||||
|
ThumbnailIntervalSeconds int
|
||||||
|
|
||||||
|
sources map[string]Source
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
115
lib/config/overrides.go
Normal file
115
lib/config/overrides.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||||
|
if !cfg.AllowAdminSettingsOverride {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, value := range overrides {
|
||||||
|
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||||
|
def, ok := Definition(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown setting %q", key)
|
||||||
|
}
|
||||||
|
if !def.Editable || def.HardLimit {
|
||||||
|
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch def.Type {
|
||||||
|
case SettingTypeBool:
|
||||||
|
parsed, err := parseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignBool(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt64:
|
||||||
|
parsed, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt:
|
||||||
|
parsed64, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingGuestUploadsEnabled:
|
||||||
|
cfg.GuestUploadsEnabled = value
|
||||||
|
case SettingAPIEnabled:
|
||||||
|
cfg.APIEnabled = value
|
||||||
|
case SettingZipDownloadsEnabled:
|
||||||
|
cfg.ZipDownloadsEnabled = value
|
||||||
|
case SettingOneTimeDownloadsEnabled:
|
||||||
|
cfg.OneTimeDownloadsEnabled = value
|
||||||
|
case SettingRenewOnAccessEnabled:
|
||||||
|
cfg.RenewOnAccessEnabled = value
|
||||||
|
case SettingRenewOnDownloadEnabled:
|
||||||
|
cfg.RenewOnDownloadEnabled = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, formatBool(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingDefaultGuestExpirySecs:
|
||||||
|
cfg.DefaultGuestExpirySeconds = value
|
||||||
|
case SettingMaxGuestExpirySecs:
|
||||||
|
cfg.MaxGuestExpirySeconds = value
|
||||||
|
case SettingOneTimeDownloadExpirySecs:
|
||||||
|
cfg.OneTimeDownloadExpirySeconds = value
|
||||||
|
case SettingDefaultUserMaxFileBytes:
|
||||||
|
cfg.DefaultUserMaxFileSizeBytes = value
|
||||||
|
case SettingDefaultUserMaxBoxBytes:
|
||||||
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
|
case SettingSessionTTLSeconds:
|
||||||
|
cfg.SessionTTLSeconds = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt(key string, value int, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingBoxPollIntervalMS:
|
||||||
|
cfg.BoxPollIntervalMS = value
|
||||||
|
case SettingThumbnailBatchSize:
|
||||||
|
cfg.ThumbnailBatchSize = value
|
||||||
|
case SettingThumbnailIntervalSeconds:
|
||||||
|
cfg.ThumbnailIntervalSeconds = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.Itoa(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.values[key] = value
|
||||||
|
cfg.sources[key] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) sourceFor(key string) Source {
|
||||||
|
source, ok := cfg.sources[key]
|
||||||
|
if !ok {
|
||||||
|
return SourceDefault
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
47
lib/config/parse.go
Normal file
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"
|
||||||
|
}
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/subtle"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"warpbox/lib/boxstore"
|
|
||||||
"warpbox/lib/config"
|
|
||||||
"warpbox/lib/helpers"
|
|
||||||
"warpbox/lib/metastore"
|
|
||||||
)
|
|
||||||
|
|
||||||
const adminSessionCookie = "warpbox_admin_session"
|
|
||||||
|
|
||||||
type adminUserRow struct {
|
|
||||||
ID string
|
|
||||||
Username string
|
|
||||||
Email string
|
|
||||||
Tags string
|
|
||||||
CreatedAt string
|
|
||||||
Disabled bool
|
|
||||||
IsCurrent bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminTagRow struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Protected bool
|
|
||||||
AdminAccess bool
|
|
||||||
UploadAllowed bool
|
|
||||||
ZipDownloadAllowed bool
|
|
||||||
OneTimeDownloadAllowed bool
|
|
||||||
RenewableAllowed bool
|
|
||||||
MaxFileSizeBytes string
|
|
||||||
MaxBoxSizeBytes string
|
|
||||||
AllowedExpirySeconds string
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminBoxRow struct {
|
|
||||||
ID string
|
|
||||||
FileCount int
|
|
||||||
TotalSizeLabel string
|
|
||||||
CreatedAt string
|
|
||||||
ExpiresAt string
|
|
||||||
Expired bool
|
|
||||||
OneTimeDownload bool
|
|
||||||
PasswordProtected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
|
||||||
admin := router.Group("/admin")
|
|
||||||
admin.Use(noStoreAdminHeaders)
|
|
||||||
admin.GET("/login", app.handleAdminLogin)
|
|
||||||
admin.POST("/login", app.handleAdminLoginPost)
|
|
||||||
|
|
||||||
protected := admin.Group("")
|
|
||||||
protected.Use(app.requireAdminSession)
|
|
||||||
protected.POST("/logout", app.handleAdminLogout)
|
|
||||||
protected.GET("", app.handleAdminDashboard)
|
|
||||||
protected.GET("/", app.handleAdminDashboard)
|
|
||||||
protected.GET("/boxes", app.handleAdminBoxes)
|
|
||||||
protected.GET("/users", app.handleAdminUsers)
|
|
||||||
protected.POST("/users", app.handleAdminUsersPost)
|
|
||||||
protected.GET("/tags", app.handleAdminTags)
|
|
||||||
protected.POST("/tags", app.handleAdminTagsPost)
|
|
||||||
protected.GET("/settings", app.handleAdminSettings)
|
|
||||||
protected.POST("/settings", app.handleAdminSettingsPost)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
|
||||||
if app.isAdminSessionValid(ctx) {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.renderAdminLogin(ctx, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
|
||||||
if !app.adminLoginEnabled {
|
|
||||||
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
|
||||||
password := ctx.PostForm("password")
|
|
||||||
user, ok, err := app.store.GetUserByUsername(username)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not load user")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
|
||||||
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
perms, err := app.permissionsForUser(user)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !perms.AdminAccess {
|
|
||||||
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not create session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
|
||||||
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
|
||||||
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
|
||||||
_ = app.store.DeleteSession(token)
|
|
||||||
}
|
|
||||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
|
||||||
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
|
||||||
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
|
||||||
"CSRFToken": app.currentCSRFToken(ctx),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries, err := boxstore.ListBoxSummaries()
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]adminBoxRow, 0, len(summaries))
|
|
||||||
totalSize := int64(0)
|
|
||||||
expiredCount := 0
|
|
||||||
for _, summary := range summaries {
|
|
||||||
totalSize += summary.TotalSize
|
|
||||||
if summary.Expired {
|
|
||||||
expiredCount++
|
|
||||||
}
|
|
||||||
rows = append(rows, adminBoxRow{
|
|
||||||
ID: summary.ID,
|
|
||||||
FileCount: summary.FileCount,
|
|
||||||
TotalSizeLabel: summary.TotalSizeLabel,
|
|
||||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
|
||||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
|
||||||
Expired: summary.Expired,
|
|
||||||
OneTimeDownload: summary.OneTimeDownload,
|
|
||||||
PasswordProtected: summary.PasswordProtected,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
|
||||||
"Boxes": rows,
|
|
||||||
"TotalBoxes": len(rows),
|
|
||||||
"TotalStorage": helpers.FormatBytes(totalSize),
|
|
||||||
"ExpiredBoxes": expiredCount,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.renderAdminUsers(ctx, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.PostForm("action") == "toggle_disabled" {
|
|
||||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
|
||||||
user, ok, err := app.store.GetUser(userID)
|
|
||||||
if err != nil || !ok {
|
|
||||||
app.renderAdminUsers(ctx, "User not found.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if current, ok := ctx.Get("adminUser"); ok {
|
|
||||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
|
||||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user.Disabled = !user.Disabled
|
|
||||||
if err := app.store.UpdateUser(user); err != nil {
|
|
||||||
app.renderAdminUsers(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := ctx.PostForm("username")
|
|
||||||
email := ctx.PostForm("email")
|
|
||||||
password := ctx.PostForm("password")
|
|
||||||
tagIDs := ctx.PostFormArray("tag_ids")
|
|
||||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
|
||||||
app.renderAdminUsers(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
|
||||||
users, err := app.store.ListUsers()
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tags, err := app.store.ListTags()
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tagNames := make(map[string]string, len(tags))
|
|
||||||
for _, tag := range tags {
|
|
||||||
tagNames[tag.ID] = tag.Name
|
|
||||||
}
|
|
||||||
sort.Slice(users, func(i int, j int) bool {
|
|
||||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
|
||||||
})
|
|
||||||
|
|
||||||
currentID := ""
|
|
||||||
if current, ok := ctx.Get("adminUser"); ok {
|
|
||||||
if currentUser, ok := current.(metastore.User); ok {
|
|
||||||
currentID = currentUser.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]adminUserRow, 0, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
names := make([]string, 0, len(user.TagIDs))
|
|
||||||
for _, tagID := range user.TagIDs {
|
|
||||||
if name := tagNames[tagID]; name != "" {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, adminUserRow{
|
|
||||||
ID: user.ID,
|
|
||||||
Username: user.Username,
|
|
||||||
Email: user.Email,
|
|
||||||
Tags: strings.Join(names, ", "),
|
|
||||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
|
||||||
Disabled: user.Disabled,
|
|
||||||
IsCurrent: user.ID == currentID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
|
||||||
"CSRFToken": app.currentCSRFToken(ctx),
|
|
||||||
"Users": rows,
|
|
||||||
"Tags": tags,
|
|
||||||
"Error": errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminTags(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.renderAdminTags(ctx, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
perms, err := parseTagPermissions(ctx)
|
|
||||||
if err != nil {
|
|
||||||
app.renderAdminTags(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tag := metastore.Tag{
|
|
||||||
Name: ctx.PostForm("name"),
|
|
||||||
Description: ctx.PostForm("description"),
|
|
||||||
Permissions: perms,
|
|
||||||
}
|
|
||||||
if err := app.store.CreateTag(&tag); err != nil {
|
|
||||||
app.renderAdminTags(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
|
||||||
tags, err := app.store.ListTags()
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sort.Slice(tags, func(i int, j int) bool {
|
|
||||||
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
|
||||||
})
|
|
||||||
rows := make([]adminTagRow, 0, len(tags))
|
|
||||||
for _, tag := range tags {
|
|
||||||
rows = append(rows, adminTagRow{
|
|
||||||
ID: tag.ID,
|
|
||||||
Name: tag.Name,
|
|
||||||
Description: tag.Description,
|
|
||||||
Protected: tag.Protected,
|
|
||||||
AdminAccess: tag.Permissions.AdminAccess,
|
|
||||||
UploadAllowed: tag.Permissions.UploadAllowed,
|
|
||||||
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
|
||||||
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
|
||||||
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
|
||||||
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
|
||||||
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
|
||||||
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
|
||||||
"CSRFToken": app.currentCSRFToken(ctx),
|
|
||||||
"Tags": rows,
|
|
||||||
"Error": errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.renderAdminSettings(ctx, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
|
||||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !app.config.AllowAdminSettingsOverride {
|
|
||||||
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, def := range config.EditableDefinitions() {
|
|
||||||
value := ctx.PostForm(def.Key)
|
|
||||||
if def.Type == config.SettingTypeBool {
|
|
||||||
value = "false"
|
|
||||||
if ctx.PostForm(def.Key) == "true" {
|
|
||||||
value = "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
|
||||||
app.renderAdminSettings(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.store.SetSetting(def.Key, value); err != nil {
|
|
||||||
app.renderAdminSettings(ctx, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyBoxstoreRuntimeConfig(app.config)
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
|
||||||
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
|
||||||
"CurrentUser": app.currentAdminUsername(ctx),
|
|
||||||
"CSRFToken": app.currentCSRFToken(ctx),
|
|
||||||
"Rows": app.config.SettingRows(),
|
|
||||||
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
|
||||||
"Error": errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) requireAdminSession(ctx *gin.Context) {
|
|
||||||
token, err := ctx.Cookie(adminSessionCookie)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
session, ok, err := app.store.GetSession(token)
|
|
||||||
if err != nil || !ok {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validAdminCSRF(ctx, session) {
|
|
||||||
ctx.String(http.StatusForbidden, "Permission denied")
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, ok, err := app.store.GetUser(session.UserID)
|
|
||||||
if err != nil || !ok || user.Disabled {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
perms, err := app.permissionsForUser(user)
|
|
||||||
if err != nil || !perms.AdminAccess {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
|
||||||
ctx.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Set("adminUser", user)
|
|
||||||
ctx.Set("adminPerms", perms)
|
|
||||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
|
||||||
ctx.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
|
||||||
token, err := ctx.Cookie(adminSessionCookie)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
session, ok, err := app.store.GetSession(token)
|
|
||||||
if err != nil || !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
user, ok, err := app.store.GetUser(session.UserID)
|
|
||||||
if err != nil || !ok || user.Disabled {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
perms, err := app.permissionsForUser(user)
|
|
||||||
return err == nil && perms.AdminAccess
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
|
||||||
tags, err := app.store.TagsByID(user.TagIDs)
|
|
||||||
if err != nil {
|
|
||||||
return metastore.EffectivePermissions{}, err
|
|
||||||
}
|
|
||||||
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
|
||||||
value, ok := ctx.Get("adminPerms")
|
|
||||||
if !ok {
|
|
||||||
ctx.String(http.StatusForbidden, "Permission denied")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
perms, ok := value.(metastore.EffectivePermissions)
|
|
||||||
if !ok || !allowed(perms) {
|
|
||||||
ctx.String(http.StatusForbidden, "Permission denied")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
|
||||||
if current, ok := ctx.Get("adminUser"); ok {
|
|
||||||
if user, ok := current.(metastore.User); ok {
|
|
||||||
return user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
|
||||||
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
|
||||||
if token, ok := value.(string); ok {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
|
||||||
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
|
||||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
|
||||||
"Error": errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func noStoreAdminHeaders(ctx *gin.Context) {
|
|
||||||
ctx.Header("Cache-Control", "no-store")
|
|
||||||
ctx.Header("Pragma", "no-cache")
|
|
||||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
|
||||||
ctx.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
|
||||||
switch ctx.Request.Method {
|
|
||||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
token := ctx.PostForm("csrf_token")
|
|
||||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
func subtleConstantTimeEqual(a string, b string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
|
||||||
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
|
||||||
if err != nil {
|
|
||||||
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
|
||||||
}
|
|
||||||
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
|
||||||
if err != nil {
|
|
||||||
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
|
||||||
}
|
|
||||||
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
|
||||||
if err != nil {
|
|
||||||
return metastore.TagPermissions{}, err
|
|
||||||
}
|
|
||||||
return metastore.TagPermissions{
|
|
||||||
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
|
||||||
AllowedExpirySeconds: expirySeconds,
|
|
||||||
MaxFileSizeBytes: maxFileSize,
|
|
||||||
MaxBoxSizeBytes: maxBoxSize,
|
|
||||||
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
|
||||||
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
|
||||||
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
|
||||||
AdminAccess: checkbox(ctx, "admin_access"),
|
|
||||||
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
|
||||||
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
|
||||||
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkbox(ctx *gin.Context, name string) bool {
|
|
||||||
return ctx.PostForm(name) == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseOptionalInt64(raw string) (*int64, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseInt(raw, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("must be an integer")
|
|
||||||
}
|
|
||||||
if value < 0 {
|
|
||||||
return nil, errors.New("must be at least 0")
|
|
||||||
}
|
|
||||||
return &value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCSVInt64(raw string) ([]int64, error) {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
values := make([]int64, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseInt(part, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
|
||||||
}
|
|
||||||
if value < 0 {
|
|
||||||
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
|
||||||
}
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionalInt64Label(value *int64) string {
|
|
||||||
if value == nil {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return strconv.FormatInt(*value, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinInt64s(values []int64) string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
parts = append(parts, strconv.FormatInt(value, 10))
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAdminTime(value time.Time) string {
|
|
||||||
if value.IsZero() {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return value.Local().Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
192
lib/server/admin_auth.go
Normal file
192
lib/server/admin_auth.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminSessionCookie = "warpbox_admin_session"
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||||
|
if app.isAdminSessionValid(ctx) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminLogin(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled {
|
||||||
|
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
user, ok, err := app.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||||
|
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !perms.AdminAccess {
|
||||||
|
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||||
|
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
||||||
|
_ = app.store.DeleteSession(token)
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
}
|
||||||
|
func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||||
|
token, err := ctx.Cookie(adminSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validAdminCSRF(ctx, session) {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
if err != nil || !perms.AdminAccess {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Set("adminUser", user)
|
||||||
|
ctx.Set("adminPerms", perms)
|
||||||
|
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
||||||
|
token, err := ctx.Cookie(adminSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
return err == nil && perms.AdminAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
||||||
|
tags, err := app.store.TagsByID(user.TagIDs)
|
||||||
|
if err != nil {
|
||||||
|
return metastore.EffectivePermissions{}, err
|
||||||
|
}
|
||||||
|
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
||||||
|
value, ok := ctx.Get("adminPerms")
|
||||||
|
if !ok {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
perms, ok := value.(metastore.EffectivePermissions)
|
||||||
|
if !ok || !allowed(perms) {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if user, ok := current.(metastore.User); ok {
|
||||||
|
return user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
||||||
|
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
||||||
|
if token, ok := value.(string); ok {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||||
|
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||||
|
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func noStoreAdminHeaders(ctx *gin.Context) {
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.Header("Pragma", "no-cache")
|
||||||
|
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
||||||
|
switch ctx.Request.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
token := ctx.PostForm("csrf_token")
|
||||||
|
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtleConstantTimeEqual(a string, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
63
lib/server/admin_boxes.go
Normal file
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")
|
||||||
|
}
|
||||||
23
lib/server/admin_routes.go
Normal file
23
lib/server/admin_routes.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.Use(noStoreAdminHeaders)
|
||||||
|
admin.GET("/login", app.handleAdminLogin)
|
||||||
|
admin.POST("/login", app.handleAdminLoginPost)
|
||||||
|
|
||||||
|
protected := admin.Group("")
|
||||||
|
protected.Use(app.requireAdminSession)
|
||||||
|
protected.POST("/logout", app.handleAdminLogout)
|
||||||
|
protected.GET("", app.handleAdminDashboard)
|
||||||
|
protected.GET("/", app.handleAdminDashboard)
|
||||||
|
protected.GET("/boxes", app.handleAdminBoxes)
|
||||||
|
protected.GET("/users", app.handleAdminUsers)
|
||||||
|
protected.POST("/users", app.handleAdminUsersPost)
|
||||||
|
protected.GET("/tags", app.handleAdminTags)
|
||||||
|
protected.POST("/tags", app.handleAdminTagsPost)
|
||||||
|
protected.GET("/settings", app.handleAdminSettings)
|
||||||
|
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
121
lib/server/admin_users.go
Normal file
121
lib/server/admin_users.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminUserRow struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Tags string
|
||||||
|
CreatedAt string
|
||||||
|
Disabled bool
|
||||||
|
IsCurrent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminUsers(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.PostForm("action") == "toggle_disabled" {
|
||||||
|
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||||
|
user, ok, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
app.renderAdminUsers(ctx, "User not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||||
|
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Disabled = !user.Disabled
|
||||||
|
if err := app.store.UpdateUser(user); err != nil {
|
||||||
|
app.renderAdminUsers(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ctx.PostForm("username")
|
||||||
|
email := ctx.PostForm("email")
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
tagIDs := ctx.PostFormArray("tag_ids")
|
||||||
|
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||||
|
app.renderAdminUsers(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||||
|
users, err := app.store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := app.store.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagNames := make(map[string]string, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagNames[tag.ID] = tag.Name
|
||||||
|
}
|
||||||
|
sort.Slice(users, func(i int, j int) bool {
|
||||||
|
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||||
|
})
|
||||||
|
|
||||||
|
currentID := ""
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if currentUser, ok := current.(metastore.User); ok {
|
||||||
|
currentID = currentUser.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]adminUserRow, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
names := make([]string, 0, len(user.TagIDs))
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if name := tagNames[tagID]; name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, adminUserRow{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Tags: strings.Join(names, ", "),
|
||||||
|
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||||
|
Disabled: user.Disabled,
|
||||||
|
IsCurrent: user.ID == currentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||||
|
"AdminSection": "users",
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
"CSRFToken": app.currentCSRFToken(ctx),
|
||||||
|
"Users": rows,
|
||||||
|
"Tags": tags,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
135
lib/server/box_auth.go
Normal file
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)
|
||||||
|
}
|
||||||
@@ -1,904 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"warpbox/lib/boxstore"
|
|
||||||
"warpbox/lib/helpers"
|
|
||||||
"warpbox/lib/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const boxAuthCookiePrefix = "warpbox_box_"
|
|
||||||
|
|
||||||
var oneTimeDownloadLocks sync.Map
|
|
||||||
|
|
||||||
func formatBrowserTime(value time.Time) string {
|
|
||||||
if value.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return value.UTC().Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleIndex(ctx *gin.Context) {
|
|
||||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
|
||||||
"RetentionOptions": app.retentionOptions(),
|
|
||||||
"DefaultRetention": app.defaultRetentionOption().Key,
|
|
||||||
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
|
||||||
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
|
||||||
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleShowBox(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := boxstore.ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
|
||||||
files = stripOneTimeThumbnailState(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadAll := "/box/" + boxID + "/download"
|
|
||||||
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
|
||||||
downloadAll = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
|
||||||
"BoxID": boxID,
|
|
||||||
"Files": files,
|
|
||||||
"FileCount": len(files),
|
|
||||||
"DownloadAll": downloadAll,
|
|
||||||
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
|
||||||
"PollMS": app.config.BoxPollIntervalMS,
|
|
||||||
"RetentionLabel": manifest.RetentionLabel,
|
|
||||||
"ExpiresAt": manifest.ExpiresAt,
|
|
||||||
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleBoxLogin(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if boxstore.IsExpired(manifest) {
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
ctx.String(http.StatusGone, "Box expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBoxLogin(ctx, boxID, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleBoxLoginPost(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if boxstore.IsExpired(manifest) {
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
ctx.String(http.StatusGone, "Box expired")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
|
|
||||||
renderBoxLogin(ctx, boxID, "The password was not accepted.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
maxAge := 24 * 60 * 60
|
|
||||||
if !manifest.ExpiresAt.IsZero() {
|
|
||||||
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
|
|
||||||
if seconds > 0 {
|
|
||||||
maxAge = seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleBoxStatus(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var files []models.BoxFile
|
|
||||||
if hasManifest && manifestFilesReady(manifest.Files) {
|
|
||||||
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
|
||||||
} else {
|
|
||||||
var err error
|
|
||||||
files, err = boxstore.ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
|
||||||
files = stripOneTimeThumbnailState(files)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleDownloadBox(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !app.config.ZipDownloadsEnabled {
|
|
||||||
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
|
||||||
app.handleOneTimeDownloadBox(ctx, boxID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasManifest && manifest.DisableZip {
|
|
||||||
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := boxstore.ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !app.writeBoxZip(ctx, boxID, files) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
|
||||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
|
||||||
lock := oneTimeDownloadLock(boxID)
|
|
||||||
lock.Lock()
|
|
||||||
defer lock.Unlock()
|
|
||||||
defer oneTimeDownloadLocks.Delete(boxID)
|
|
||||||
|
|
||||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
|
|
||||||
ctx.String(http.StatusGone, "Box already consumed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := boxstore.ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !allFilesComplete(files) {
|
|
||||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.config.OneTimeDownloadRetryOnFailure {
|
|
||||||
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Consumed = true
|
|
||||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !app.writeBoxZip(ctx, boxID, files) {
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
|
||||||
writeBoxZipHeaders(ctx, boxID)
|
|
||||||
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
|
|
||||||
ctx.Status(http.StatusInternalServerError)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
|
|
||||||
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tempPath := tempZip.Name()
|
|
||||||
defer os.Remove(tempPath)
|
|
||||||
|
|
||||||
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
|
|
||||||
tempZip.Close()
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := tempZip.Seek(0, 0); err != nil {
|
|
||||||
tempZip.Close()
|
|
||||||
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeBoxZipHeaders(ctx, boxID)
|
|
||||||
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
|
|
||||||
tempZip.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := tempZip.Close(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Consumed = true
|
|
||||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
|
|
||||||
ctx.Header("Content-Type", "application/zip")
|
|
||||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
|
|
||||||
zipWriter := zip.NewWriter(destination)
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsComplete {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := zipWriter.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
|
||||||
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
|
|
||||||
return lock.(*sync.Mutex)
|
|
||||||
}
|
|
||||||
|
|
||||||
func allFilesComplete(files []models.BoxFile) bool {
|
|
||||||
if len(files) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsComplete {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func manifestFilesReady(files []models.BoxFile) bool {
|
|
||||||
if len(files) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, file := range files {
|
|
||||||
if file.Status != models.FileStatusReady {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
|
|
||||||
stripped := make([]models.BoxFile, 0, len(files))
|
|
||||||
for _, file := range files {
|
|
||||||
file.ThumbnailPath = nil
|
|
||||||
file.ThumbnailURL = ""
|
|
||||||
if file.ThumbnailStatus == "" {
|
|
||||||
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
|
|
||||||
}
|
|
||||||
stripped = append(stripped, file)
|
|
||||||
}
|
|
||||||
return stripped
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
|
||||||
if !boxstore.ValidBoxID(boxID) || !ok {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
|
||||||
if !authorized {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
|
||||||
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
|
|
||||||
if !ok {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "File not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.FileAttachment(path, filename)
|
|
||||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
|
||||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
fileID := ctx.Param("file_id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
|
||||||
if !authorized {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
|
||||||
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
|
|
||||||
if !ok {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Thumbnail not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Header("Content-Type", "image/jpeg")
|
|
||||||
ctx.File(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleCreateBox(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.limitRequestBody(ctx)
|
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request models.CreateBoxRequest
|
|
||||||
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := boxstore.CreateManifest(boxID, request)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.limitRequestBody(ctx)
|
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
fileID := ctx.Param("file_id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := ctx.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
|
||||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
|
||||||
if err != nil {
|
|
||||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.limitRequestBody(ctx)
|
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
fileID := ctx.Param("file_id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request models.UpdateFileStatusRequest
|
|
||||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if request.Status == models.FileStatusReady {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.rejectExpiredManifestBox(boxID); err != nil {
|
|
||||||
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.limitRequestBody(ctx)
|
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := ctx.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|
||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.limitRequestBody(ctx)
|
|
||||||
|
|
||||||
form, err := ctx.MultipartForm()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files := form.File["files"]
|
|
||||||
if len(files) == 0 {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range files {
|
|
||||||
if err := app.validateFileSize(file.Size); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
totalSize += file.Size
|
|
||||||
}
|
|
||||||
if err := app.validateBoxSize(totalSize); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
|
|
||||||
if retentionKey == "" {
|
|
||||||
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
|
|
||||||
}
|
|
||||||
allowZip := true
|
|
||||||
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
|
|
||||||
allowZip = false
|
|
||||||
}
|
|
||||||
request := models.CreateBoxRequest{
|
|
||||||
RetentionKey: retentionKey,
|
|
||||||
Password: ctx.PostForm("password"),
|
|
||||||
AllowZip: &allowZip,
|
|
||||||
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
|
|
||||||
}
|
|
||||||
for _, file := range files {
|
|
||||||
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
|
||||||
}
|
|
||||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
manifestFiles, err := boxstore.CreateManifest(boxID, request)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFiles := make([]models.BoxFile, 0, len(files))
|
|
||||||
for index, file := range files {
|
|
||||||
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
|
|
||||||
if err != nil {
|
|
||||||
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFiles = append(savedFiles, savedFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return models.BoxManifest{}, false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if boxstore.IsExpired(manifest) {
|
|
||||||
boxstore.DeleteBox(boxID)
|
|
||||||
if wantsHTML {
|
|
||||||
ctx.String(http.StatusGone, "Box expired")
|
|
||||||
} else {
|
|
||||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
|
|
||||||
}
|
|
||||||
return manifest, true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if manifest.OneTimeDownload && manifest.Consumed {
|
|
||||||
if wantsHTML {
|
|
||||||
ctx.String(http.StatusGone, "Box already consumed")
|
|
||||||
} else {
|
|
||||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
|
|
||||||
}
|
|
||||||
return manifest, true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
|
|
||||||
if wantsHTML {
|
|
||||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
|
|
||||||
} else {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
|
|
||||||
}
|
|
||||||
return manifest, true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.config.RenewOnAccessEnabled {
|
|
||||||
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
|
|
||||||
manifest = renewed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest, true, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
|
|
||||||
token, err := ctx.Cookie(boxAuthCookieName(boxID))
|
|
||||||
return err == nil && boxstore.VerifyAuthToken(manifest, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boxAuthCookieName(boxID string) string {
|
|
||||||
return boxAuthCookiePrefix + boxID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) requireAPI(ctx *gin.Context) bool {
|
|
||||||
if app.config.APIEnabled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
|
||||||
if app.config.GuestUploadsEnabled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
|
||||||
if request == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !app.retentionAllowed(request.RetentionKey) {
|
|
||||||
return fmt.Errorf("Retention option is not allowed")
|
|
||||||
}
|
|
||||||
if !app.config.ZipDownloadsEnabled {
|
|
||||||
allowZip := false
|
|
||||||
request.AllowZip = &allowZip
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
|
||||||
return fmt.Errorf("One-time downloads are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSize := int64(0)
|
|
||||||
for _, file := range request.Files {
|
|
||||||
if err := app.validateFileSize(file.Size); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
totalSize += file.Size
|
|
||||||
}
|
|
||||||
return app.validateBoxSize(totalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
|
||||||
if err := app.validateFileSize(size); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := boxstore.ListFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
totalSize := size
|
|
||||||
for _, file := range files {
|
|
||||||
totalSize += file.Size
|
|
||||||
}
|
|
||||||
return app.validateBoxSize(totalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
|
||||||
if err := app.validateFileSize(size); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return app.validateIncomingFile(boxID, size)
|
|
||||||
}
|
|
||||||
if boxstore.IsExpired(manifest) {
|
|
||||||
_ = boxstore.DeleteBox(boxID)
|
|
||||||
return fmt.Errorf("Box expired")
|
|
||||||
}
|
|
||||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
totalSize := int64(0)
|
|
||||||
found := false
|
|
||||||
for _, file := range manifest.Files {
|
|
||||||
if file.ID == fileID {
|
|
||||||
totalSize += size
|
|
||||||
found = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalSize += file.Size
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
totalSize += size
|
|
||||||
}
|
|
||||||
return app.validateBoxSize(totalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) validateFileSize(size int64) error {
|
|
||||||
if size < 0 {
|
|
||||||
return fmt.Errorf("File size cannot be negative")
|
|
||||||
}
|
|
||||||
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
|
|
||||||
return fmt.Errorf("File exceeds the global max file size")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) validateBoxSize(size int64) error {
|
|
||||||
if size < 0 {
|
|
||||||
return fmt.Errorf("Box size cannot be negative")
|
|
||||||
}
|
|
||||||
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
|
|
||||||
return fmt.Errorf("Box exceeds the global max box size")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) rejectExpiredManifestBox(boxID string) error {
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !boxstore.IsExpired(manifest) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_ = boxstore.DeleteBox(boxID)
|
|
||||||
return fmt.Errorf("Box expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
|
||||||
limit := app.maxRequestBodyBytes()
|
|
||||||
if limit <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) maxRequestBodyBytes() int64 {
|
|
||||||
limit := app.config.GlobalMaxBoxSizeBytes
|
|
||||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
|
||||||
limit = app.config.GlobalMaxFileSizeBytes
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return limit + 10*1024*1024
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) retentionAllowed(key string) bool {
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if key == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, option := range app.retentionOptions() {
|
|
||||||
if option.Key == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) retentionOptions() []models.RetentionOption {
|
|
||||||
allOptions := boxstore.RetentionOptions()
|
|
||||||
options := make([]models.RetentionOption, 0, len(allOptions))
|
|
||||||
for _, option := range allOptions {
|
|
||||||
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
options = append(options, option)
|
|
||||||
}
|
|
||||||
if len(options) == 0 {
|
|
||||||
return allOptions[:1]
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) defaultRetentionOption() models.RetentionOption {
|
|
||||||
options := app.retentionOptions()
|
|
||||||
for _, option := range options {
|
|
||||||
if option.Seconds == app.config.DefaultGuestExpirySeconds {
|
|
||||||
return option
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
|
||||||
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
|
||||||
"BoxID": boxID,
|
|
||||||
"BoxUser": "WarpBox\\" + boxID,
|
|
||||||
"ErrorMessage": errorMessage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
100
lib/server/pages.go
Normal file
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]
|
||||||
|
}
|
||||||
236
lib/server/uploads.go
Normal file
236
lib/server/uploads.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBody(ctx)
|
||||||
|
|
||||||
|
boxID, err := boxstore.NewBoxID()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request models.CreateBoxRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.CreateManifest(boxID, request)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBody(ctx)
|
||||||
|
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
fileID := ctx.Param("file_id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||||
|
if err != nil {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBody(ctx)
|
||||||
|
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
fileID := ctx.Param("file_id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request models.UpdateFileStatusRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Status == models.FileStatusReady {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.rejectExpiredManifestBox(boxID); err != nil {
|
||||||
|
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBody(ctx)
|
||||||
|
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBody(ctx)
|
||||||
|
|
||||||
|
form, err := ctx.MultipartForm()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := form.File["files"]
|
||||||
|
if len(files) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range files {
|
||||||
|
if err := app.validateFileSize(file.Size); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
if err := app.validateBoxSize(totalSize); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxID, err := boxstore.NewBoxID()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
|
||||||
|
if retentionKey == "" {
|
||||||
|
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
|
||||||
|
}
|
||||||
|
allowZip := true
|
||||||
|
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
|
||||||
|
allowZip = false
|
||||||
|
}
|
||||||
|
request := models.CreateBoxRequest{
|
||||||
|
RetentionKey: retentionKey,
|
||||||
|
Password: ctx.PostForm("password"),
|
||||||
|
AllowZip: &allowZip,
|
||||||
|
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
||||||
|
}
|
||||||
|
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestFiles, err := boxstore.CreateManifest(boxID, request)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedFiles := make([]models.BoxFile, 0, len(files))
|
||||||
|
for index, file := range files {
|
||||||
|
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedFiles = append(savedFiles, savedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||||
|
}
|
||||||
155
lib/server/validation.go
Normal file
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
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
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;
|
||||||
|
}
|
||||||
|
|
||||||
148
static/css/upload/options.css
Normal file
148
static/css/upload/options.css
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
.box-options-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 100%;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-options-form.is-locked {
|
||||||
|
opacity: .82;
|
||||||
|
filter: grayscale(.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-options-form.is-locked::after {
|
||||||
|
content: "Box sealed after upload";
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check {
|
||||||
|
position: relative;
|
||||||
|
min-height: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check span {
|
||||||
|
position: relative;
|
||||||
|
min-height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check span::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
box-shadow: inset -1px -1px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-check input[type="checkbox"]:checked + span::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 6px;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
color: #000000;
|
||||||
|
background: #000000;
|
||||||
|
box-shadow:
|
||||||
|
2px 2px 0 #000000,
|
||||||
|
4px 4px 0 #000000,
|
||||||
|
6px 2px 0 #000000,
|
||||||
|
8px 0 0 #000000,
|
||||||
|
10px -2px 0 #000000;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-select,
|
||||||
|
.upload-text-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 22px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text-input:disabled,
|
||||||
|
.upload-select:disabled,
|
||||||
|
.box-options-form.is-locked input[readonly],
|
||||||
|
.box-options-form.is-locked input:disabled,
|
||||||
|
.box-options-form.is-locked select:disabled {
|
||||||
|
color: #404040;
|
||||||
|
background: repeating-linear-gradient(45deg, #d0d0d0 0 4px, #c7c7c7 4px 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-row.is-visible {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-field {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-state {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 3px;
|
||||||
|
color: #000078;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-field.is-checking::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 2px;
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0,0,120,.16) 0 8px, rgba(15,128,205,.16) 8px 16px);
|
||||||
|
animation: api-key-scan 700ms steps(6, end) infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
41
static/css/upload/panel.css
Normal file
41
static/css/upload/panel.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.upload-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 8px 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 270px;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-heading {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtext {
|
||||||
|
margin: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
323
static/css/upload/queue.css
Normal file
323
static/css/upload/queue.css
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
.upload-quota {
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #c7d8f2;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #404040;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #e9f2ff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota.is-quota-warning {
|
||||||
|
background: repeating-linear-gradient(45deg, #ffdede 0 5px, #fff2a8 5px 10px);
|
||||||
|
border-color: #800000;
|
||||||
|
animation: quota-warning-breathe 900ms steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota-track,
|
||||||
|
.upload-overall-track,
|
||||||
|
.upload-progress {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.05) 0 1px, transparent 1px 18px);
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota-bar,
|
||||||
|
.upload-overall-bar,
|
||||||
|
.upload-progress-bar {
|
||||||
|
display: block;
|
||||||
|
width: 0%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #000078;
|
||||||
|
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.12) 0 1px, transparent 1px 18px);
|
||||||
|
transform-origin: left center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-quota-bar.is-over-quota {
|
||||||
|
background-image: repeating-linear-gradient(45deg, #800000 0 7px, #ffcc00 7px 14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 154px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000000;
|
||||||
|
background: repeating-linear-gradient(45deg, #dfdfdf 0 4px, #e9e9e9 4px 8px), #dfdfdf;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080, inset 2px 2px 0 rgba(0,0,0,.18), 0 1px 0 rgba(255,255,255,.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone.is-dragging,
|
||||||
|
.upload-dropzone:hover {
|
||||||
|
background: repeating-linear-gradient(45deg, #c7d8f2 0 4px, #d8e5f8 4px 8px), #c7d8f2;
|
||||||
|
outline: 2px dashed #000078;
|
||||||
|
outline-offset: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone.is-current-step {
|
||||||
|
animation: dropzone-attention 1500ms steps(5, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone.is-locked {
|
||||||
|
opacity: .72;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon-img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-primary {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-secondary {
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-linklike {
|
||||||
|
color: #000078;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #dfdfdf;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-detail-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-count {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-empty-state {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: #555555;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) 82px 30px;
|
||||||
|
grid-template-rows: 20px 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 38px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
column-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row:nth-child(odd) { background: rgba(255,255,255,.92); }
|
||||||
|
.upload-file-row:nth-child(even) { background: rgba(240,244,255,.88); }
|
||||||
|
.upload-file-row:hover { background: #d8e5f8; }
|
||||||
|
.upload-file-row.is-working { animation: upload-row-loading 900ms steps(2, end) infinite; }
|
||||||
|
.upload-file-row.is-failed { background: #ffe2e2 !important; }
|
||||||
|
.upload-file-row.is-too-large { position: relative; background: #fff0b8 !important; animation: row-warning-breathe 900ms steps(4, end) infinite; }
|
||||||
|
|
||||||
|
.upload-file-row.is-too-large::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(90deg, #800000 0 8px, #ffcc00 8px 16px) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-icon {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row.has-thumbnail .upload-file-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-name,
|
||||||
|
.upload-file-size {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-size {
|
||||||
|
text-align: right;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-remove {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
justify-self: end;
|
||||||
|
width: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
grid-column: 2 / 4;
|
||||||
|
grid-row: 2;
|
||||||
|
height: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-row.is-uploaded .upload-progress-bar { background-color: #008000; }
|
||||||
|
.upload-file-row.is-failed .upload-progress-bar { width: 100%; background-color: #800000; }
|
||||||
|
|
||||||
|
.upload-progress-bar.just-completed,
|
||||||
|
.upload-overall-bar.just-completed {
|
||||||
|
animation: progress-impact-bar 520ms steps(5, end) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-bar.just-completed::after,
|
||||||
|
.upload-overall-bar.just-completed::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -7px;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 22px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: repeating-linear-gradient(45deg, rgba(255,255,255,.95) 0 2px, rgba(0,255,102,.85) 2px 4px, transparent 4px 6px);
|
||||||
|
box-shadow: 0 0 0 1px #ffffff, 0 0 8px #00ff66;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: progress-impact-spark 520ms steps(5, end) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(0,0,0,.16), inset -1px -1px 0 rgba(255,255,255,.75);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result.is-current-step {
|
||||||
|
animation: share-ready-pulse 1100ms steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result-label { font-weight: bold; }
|
||||||
|
.upload-result-link { min-width: 0; overflow: hidden; color: #000078; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-result-link.is-empty { color: #555555; text-decoration: none; pointer-events: none; }
|
||||||
|
.upload-share-button { min-width: 72px; width: 72px; height: 24px; font-size: 12px; line-height: 12px; }
|
||||||
|
|
||||||
|
.upload-overall {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 42px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-overall-track {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-overall-percent {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
123
static/css/upload/responsive.css
Normal file
123
static/css/upload/responsive.css
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
@keyframes upload-row-loading { 0% { background-color: #ffffff; } 100% { background-color: #e6e6e6; } }
|
||||||
|
@keyframes quota-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.08); } }
|
||||||
|
@keyframes row-warning-breathe { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.12); } }
|
||||||
|
@keyframes dropzone-attention { 0%, 100% { filter: brightness(1); transform: translateY(0); } 50% { filter: brightness(1.07); transform: translateY(-1px); } }
|
||||||
|
@keyframes share-ready-pulse { 50% { filter: brightness(1.08); box-shadow: 0 0 0 2px #000078; } }
|
||||||
|
@keyframes start-ready-rainbow-breathe { 0%, 100% { transform: rotate(-.35deg) scale(1); } 50% { transform: rotate(.35deg) scale(1.016); } }
|
||||||
|
@keyframes start-border-rainbow-slide { from { background-position: 0% 50%; } to { background-position: 100% 50%; } }
|
||||||
|
@keyframes progress-impact-bar { 0% { filter: brightness(1); } 35% { filter: brightness(1.75); } 100% { filter: brightness(1); } }
|
||||||
|
@keyframes progress-impact-spark { 0% { opacity: 0; transform: translateY(-50%) scale(.7); } 30% { opacity: 1; transform: translateY(-50%) scale(1.18); } 100% { opacity: 0; transform: translateY(-50%) scale(.7); } }
|
||||||
|
@keyframes terminal-cursor { 50% { opacity: 0; } }
|
||||||
|
@keyframes popup-open-v10 { from { transform: translate(-50%, -48%) scale(.97); opacity: .35; } to { transform: translate(-50%, -50%) scale(1); opacity: 1; } }
|
||||||
|
@keyframes toast-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
@keyframes toast-buzz { 0%, 100% { margin-right: 0; } 25% { margin-right: 2px; } 50% { margin-right: -2px; } }
|
||||||
|
@keyframes api-key-scan { to { background-position: 32px 0; } }
|
||||||
|
|
||||||
|
@media (max-width: 1320px) {
|
||||||
|
body { height: auto; min-height: 100vh; overflow-y: auto; }
|
||||||
|
.upload-main { height: auto; min-height: 100vh; place-items: start center; overflow: visible; }
|
||||||
|
.desktop-wrap {
|
||||||
|
--window-height: 680px;
|
||||||
|
grid-template-columns: minmax(0, 820px);
|
||||||
|
grid-template-rows: var(--window-height) auto;
|
||||||
|
width: min(820px, 100%);
|
||||||
|
max-width: 820px;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.side-stack {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 350px 210px 132px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.side-panel,
|
||||||
|
.helper-window {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1440px) {
|
||||||
|
.desktop-wrap { --window-height: 780px; }
|
||||||
|
.side-stack { grid-template-rows: 372px 230px 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.upload-main {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
place-items: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.desktop-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
min-height: 100dvh;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.upload-window {
|
||||||
|
min-height: 100dvh;
|
||||||
|
height: auto;
|
||||||
|
width: 100vw;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.side-stack {
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
padding: 0 6px 12px;
|
||||||
|
}
|
||||||
|
.side-panel:first-child { min-height: 360px; }
|
||||||
|
.side-panel:nth-child(2) { min-height: 210px; }
|
||||||
|
.helper-window { min-height: 128px; }
|
||||||
|
.upload-header { grid-template-columns: 1fr; }
|
||||||
|
.upload-panel { margin: 0 6px 8px; padding: 10px; }
|
||||||
|
.upload-dropzone { min-height: 118px; padding: 14px 10px; }
|
||||||
|
.upload-primary { font-size: 16px; }
|
||||||
|
.upload-details { flex-wrap: wrap; gap: 4px; }
|
||||||
|
.upload-file-count { margin-left: 0; width: 100%; }
|
||||||
|
.upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 58px 28px; padding: 4px 5px; font-size: 12px; }
|
||||||
|
.upload-result { grid-template-columns: 1fr 72px; }
|
||||||
|
.upload-result-label { grid-column: 1 / 3; }
|
||||||
|
.upload-actions { justify-content: stretch; }
|
||||||
|
.upload-actions .win98-button { flex: 1; min-width: 0; }
|
||||||
|
.menu-bar { overflow-x: auto; }
|
||||||
|
.menu-popup { position: fixed; left: 6px; right: 6px; top: 50px; min-width: 0; }
|
||||||
|
.popup-window {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: none;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.popup-window .win98-titlebar { height: 32px; }
|
||||||
|
.popup-close { width: 28px; height: 24px; font-size: 18px; font-weight: bold; }
|
||||||
|
.popup-body { max-height: calc(100dvh - 40px); }
|
||||||
|
.popup-window.is-visible { animation: popup-open-mobile-v10 160ms steps(5, end); }
|
||||||
|
@keyframes popup-open-mobile-v10 { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
:root { --base-font-size: 13px; }
|
||||||
|
.win98-titlebar h1 { font-size: 13px; }
|
||||||
|
.upload-file-size { display: none; }
|
||||||
|
.upload-file-row { grid-template-columns: 22px minmax(0, 1fr) 28px; }
|
||||||
|
.upload-file-remove { grid-column: 3; }
|
||||||
|
.upload-progress { grid-column: 2 / 3; }
|
||||||
|
}
|
||||||
50
static/css/upload/sidebar.css
Normal file
50
static/css/upload/sidebar.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
.upload-statusbar {
|
||||||
|
grid-template-columns: 1fr 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-stack {
|
||||||
|
width: var(--side-width);
|
||||||
|
min-width: var(--side-width);
|
||||||
|
max-width: var(--side-width);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--side-width);
|
||||||
|
grid-template-rows: 350px 210px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel,
|
||||||
|
.helper-window {
|
||||||
|
width: var(--side-width);
|
||||||
|
min-width: var(--side-width);
|
||||||
|
max-width: var(--side-width);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-body,
|
||||||
|
.helper-body,
|
||||||
|
.popup-body {
|
||||||
|
margin: 0 6px 6px;
|
||||||
|
padding: 9px;
|
||||||
|
color: #000000;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
54
static/css/upload/terminal.css
Normal file
54
static/css/upload/terminal.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.terminal-box {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 104px;
|
||||||
|
max-height: 134px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
color: #b4efbd;
|
||||||
|
background-color: #030403;
|
||||||
|
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
|
||||||
|
border: 0;
|
||||||
|
box-shadow: inset 1px 1px 0 #000000, inset -1px -1px 0 rgba(255,255,255,.22);
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-box::after {
|
||||||
|
content: "█";
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: #7dff8a;
|
||||||
|
animation: terminal-cursor 1s steps(2, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-muted {
|
||||||
|
color: #79ad83;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-copy-button {
|
||||||
|
min-width: 148px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-body {
|
||||||
|
height: calc(100% - 34px);
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
1265
static/js/app.js
1265
static/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,7 @@ const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
|
|||||||
let contextFile = null;
|
let contextFile = null;
|
||||||
let lastStatusSignature = "";
|
let lastStatusSignature = "";
|
||||||
|
|
||||||
function htmlEscape(value) {
|
const htmlEscape = window.WarpBoxUI.htmlEscape;
|
||||||
return String(value || "")
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message, type = "info") {
|
function showToast(message, type = "info") {
|
||||||
window.WarpBoxUI.toast(message, type, { target: toast });
|
window.WarpBoxUI.toast(message, type, { target: toast });
|
||||||
@@ -302,23 +295,39 @@ function startStagedPolling(baseMS) {
|
|||||||
window.setTimeout(tick, stages[0].interval);
|
window.setTimeout(tick, stages[0].interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runBoxAction(action) {
|
||||||
|
const actions = {
|
||||||
|
"fake-close": () => showToast("Close clicked. The download window is emotionally attached.", "warning"),
|
||||||
|
minimize: () => showToast("Minimize clicked. WarpBox refuses to disappear quietly."),
|
||||||
|
"toggle-fit": () => {
|
||||||
|
document.body.classList.toggle("fit-window");
|
||||||
|
showToast("Maximize clicked. The window is doing its best.");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[action]?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runContextAction(action, item) {
|
||||||
|
const actions = {
|
||||||
|
preview: () => previewFile(item),
|
||||||
|
download: () => downloadFile(item),
|
||||||
|
properties: () => showProperties(item),
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[action]?.();
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
const action = event.target.closest("[data-action]")?.dataset.action;
|
const action = event.target.closest("[data-action]")?.dataset.action;
|
||||||
if (action === "fake-close") showToast("Close clicked. The download window is emotionally attached.", "warning");
|
if (action) runBoxAction(action);
|
||||||
if (action === "minimize") showToast("Minimize clicked. WarpBox refuses to disappear quietly.");
|
|
||||||
if (action === "toggle-fit") {
|
|
||||||
document.body.classList.toggle("fit-window");
|
|
||||||
showToast("Maximize clicked. The window is doing its best.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction;
|
const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction;
|
||||||
if (contextAction && contextFile) {
|
if (contextAction && contextFile) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const item = contextFile;
|
const item = contextFile;
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
if (contextAction === "preview") previewFile(item);
|
runContextAction(contextAction, item);
|
||||||
if (contextAction === "download") downloadFile(item);
|
|
||||||
if (contextAction === "properties") showProperties(item);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
139
static/js/upload/api.js
Normal file
139
static/js/upload/api.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
async function createBox() {
|
||||||
|
const response = await fetch("/box", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
retention_key: el.expiry?.value || defaultRetention,
|
||||||
|
password: el.password?.value || "",
|
||||||
|
allow_zip: isOneTimeDownloadSelected() || !el.allowZip || el.allowZip.checked,
|
||||||
|
files: files.map((item) => ({ name: item.displayName, size: item.file.size })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await readJSON(response);
|
||||||
|
if (!response.ok) throw new Error(result.error || "Could not create upload box");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJSON(response) {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markFileStatus(item, status) {
|
||||||
|
if (!item.boxID || !item.boxFile) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort only. The upload endpoint also marks hard failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFileFailed(item, message) {
|
||||||
|
item.failed = true;
|
||||||
|
item.uploaded = false;
|
||||||
|
item.error = message || "Failed to upload";
|
||||||
|
item.loaded = item.file.size;
|
||||||
|
item.row?.classList.remove("is-working", "is-uploaded");
|
||||||
|
item.row?.classList.add("is-failed");
|
||||||
|
if (item.row) item.row.title = item.error;
|
||||||
|
setRowProgress(item, 100);
|
||||||
|
updateOverallProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCompletedImpact(item) {
|
||||||
|
const key = item.boxFile?.id || item.displayName;
|
||||||
|
if (completedImpactKeys.has(key)) return;
|
||||||
|
completedImpactKeys.add(key);
|
||||||
|
flashProgressBar(item.row?.querySelector(".upload-progress-bar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(item, onComplete) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", item.file, item.displayName);
|
||||||
|
|
||||||
|
xhr.open("POST", item.boxFile.upload_path);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("loadstart", () => {
|
||||||
|
item.loaded = 0;
|
||||||
|
item.failed = false;
|
||||||
|
item.uploaded = false;
|
||||||
|
item.row?.classList.remove("is-failed", "is-uploaded");
|
||||||
|
item.row?.classList.add("is-working");
|
||||||
|
setRowProgress(item, 2);
|
||||||
|
updateOverallProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", (event) => {
|
||||||
|
if (!event.lengthComputable) return;
|
||||||
|
item.loaded = Math.min(event.loaded, item.file.size);
|
||||||
|
const percent = (event.loaded / event.total) * 100;
|
||||||
|
setRowProgress(item, percent >= 100 ? 99 : percent);
|
||||||
|
updateOverallProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("load", async () => {
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
let message = "Upload failed";
|
||||||
|
try {
|
||||||
|
message = JSON.parse(xhr.responseText).error || message;
|
||||||
|
} catch (_) {}
|
||||||
|
setFileFailed(item, message);
|
||||||
|
await markFileStatus(item, "failed");
|
||||||
|
reject(new Error(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.uploaded = true;
|
||||||
|
item.failed = false;
|
||||||
|
item.loaded = item.file.size;
|
||||||
|
item.row?.classList.remove("is-working", "is-failed");
|
||||||
|
item.row?.classList.add("is-uploaded");
|
||||||
|
if (item.row) item.row.title = "Uploaded";
|
||||||
|
setRowProgress(item, 100);
|
||||||
|
markCompletedImpact(item);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(xhr.responseText);
|
||||||
|
if (result.file) {
|
||||||
|
item.boxFile = result.file;
|
||||||
|
const icon = item.row?.querySelector(".upload-file-icon");
|
||||||
|
if (icon && result.file.thumbnail_path) {
|
||||||
|
item.row.classList.add("has-thumbnail");
|
||||||
|
icon.src = result.file.thumbnail_path;
|
||||||
|
} else if (icon && result.file.icon_path && !item.previewURL) {
|
||||||
|
icon.src = result.file.icon_path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
updateOverallProgress();
|
||||||
|
onComplete();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("error", async () => {
|
||||||
|
setFileFailed(item, "Network error while uploading");
|
||||||
|
await markFileStatus(item, "failed");
|
||||||
|
reject(new Error("Network error while uploading"));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("abort", async () => {
|
||||||
|
setFileFailed(item, "Upload cancelled");
|
||||||
|
await markFileStatus(item, "failed");
|
||||||
|
reject(new Error("Upload cancelled"));
|
||||||
|
});
|
||||||
|
|
||||||
|
markFileStatus(item, "uploading");
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
232
static/js/upload/dom.js
Normal file
232
static/js/upload/dom.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
function setStatus(message) {
|
||||||
|
if (el.statusText) el.statusText.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = "info") {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: el.toast });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenus() {
|
||||||
|
document.querySelectorAll(".menu-item.is-open").forEach((node) => {
|
||||||
|
node.classList.remove("is-open");
|
||||||
|
node.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disabledReasonFor(target) {
|
||||||
|
const control = target.closest("[data-disabled-reason], button, input, select, textarea, .upload-dropzone, .option-check, .option-row");
|
||||||
|
if (!control) return "";
|
||||||
|
if (control.classList.contains("option-check") || control.classList.contains("option-row")) {
|
||||||
|
const nested = control.querySelector("input, select, textarea");
|
||||||
|
if (nested?.disabled || nested?.readOnly || nested?.getAttribute("aria-disabled") === "true") {
|
||||||
|
return nested.dataset.disabledReason || "This option is disabled right now.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (control.classList.contains("upload-dropzone") && uploadLocked) {
|
||||||
|
return control.dataset.disabledReason || "The current box is sealed after upload. Press Clear to start a new box.";
|
||||||
|
}
|
||||||
|
if (control.disabled || control.readOnly || control.getAttribute("aria-disabled") === "true") {
|
||||||
|
return control.dataset.disabledReason || control.title || "This control is disabled right now.";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceDisabledReason(event) {
|
||||||
|
const reason = disabledReasonFor(event.target);
|
||||||
|
if (!reason) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
closeMenus();
|
||||||
|
showToast(reason, "warning");
|
||||||
|
setStatus(reason);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatusAnimation() {
|
||||||
|
if (statusTimer) {
|
||||||
|
clearInterval(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateUploadStatus(getPrefix) {
|
||||||
|
let dotCount = 0;
|
||||||
|
stopStatusAnimation();
|
||||||
|
statusTimer = setInterval(() => {
|
||||||
|
dotCount = (dotCount % 3) + 1;
|
||||||
|
setStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShareUrl(url) {
|
||||||
|
shareUrl = url ? new URL(url, window.location.origin).toString() : "";
|
||||||
|
if (!el.shareLink || !el.copyButton) return;
|
||||||
|
el.shareLink.textContent = shareUrl || "Not created yet";
|
||||||
|
el.shareLink.href = shareUrl || "#";
|
||||||
|
el.shareLink.title = shareUrl;
|
||||||
|
el.shareLink.classList.toggle("is-empty", !shareUrl);
|
||||||
|
el.shareLink.setAttribute("aria-disabled", shareUrl ? "false" : "true");
|
||||||
|
el.copyButton.disabled = false;
|
||||||
|
el.copyButton.setAttribute("aria-disabled", shareUrl ? "false" : "true");
|
||||||
|
el.copyButton.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
|
||||||
|
updateDisabledReasons();
|
||||||
|
updateTerminal();
|
||||||
|
updateCurrentStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverallProgress(percent) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
const display = `${Math.round(clamped)}%`;
|
||||||
|
if (el.overallBar) el.overallBar.style.width = display;
|
||||||
|
if (el.overallPercent) el.overallPercent.textContent = display;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashProgressBar(bar) {
|
||||||
|
if (!bar) return;
|
||||||
|
bar.classList.remove("just-completed");
|
||||||
|
void bar.offsetWidth;
|
||||||
|
bar.classList.add("just-completed");
|
||||||
|
setTimeout(() => bar.classList.remove("just-completed"), 620);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowProgress(item, percent) {
|
||||||
|
const bar = item.row?.querySelector(".upload-progress-bar");
|
||||||
|
if (bar) bar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentStep() {
|
||||||
|
const hasFiles = files.length > 0;
|
||||||
|
const allDone = hasFiles && files.every((item) => item.uploaded);
|
||||||
|
el.dropzone?.classList.toggle("is-current-step", uploadsEnabled && !hasFiles && !uploadLocked);
|
||||||
|
el.startButton?.classList.toggle("is-current-step", uploadsEnabled && hasFiles && !allDone && !uploadLocked && !hasQuotaError());
|
||||||
|
document.querySelector(".upload-result")?.classList.toggle("is-current-step", allDone && Boolean(shareUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaWarningMessage(incoming = []) {
|
||||||
|
const combined = [...files, ...incoming];
|
||||||
|
const tooBig = maxFileBytes ? combined.filter((item) => item.file.size > maxFileBytes) : [];
|
||||||
|
const total = combined.reduce((sum, item) => sum + item.file.size, 0);
|
||||||
|
if (tooBig.length) {
|
||||||
|
const list = tooBig.slice(0, 4).map((item) => `${item.displayName} (${formatBytes(item.file.size)})`).join(", ");
|
||||||
|
const more = tooBig.length > 4 ? ` and ${tooBig.length - 4} more` : "";
|
||||||
|
return `These files are over the single-file limit of ${formatBytes(maxFileBytes)}: ${list}${more}. Remove them before uploading.`;
|
||||||
|
}
|
||||||
|
if (maxBoxBytes && total > maxBoxBytes) {
|
||||||
|
return `This box is ${formatBytes(total - maxBoxBytes)} over the ${formatBytes(maxBoxBytes)} limit. Remove some files before uploading.`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLimitHint() {
|
||||||
|
if (!el.limitHint) return;
|
||||||
|
const parts = [];
|
||||||
|
if (maxBoxBytes) parts.push(`Max box: ${formatBytes(maxBoxBytes)}`);
|
||||||
|
if (maxFileBytes) parts.push(`max file: ${formatBytes(maxFileBytes)}`);
|
||||||
|
parts.push("links expire automatically");
|
||||||
|
el.limitHint.textContent = parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuota() {
|
||||||
|
const used = totalBytes();
|
||||||
|
const limitText = maxBoxBytes ? ` / ${formatBytes(maxBoxBytes)}` : "";
|
||||||
|
const overQuota = isOverBoxQuota();
|
||||||
|
const overFile = oversizedFiles().length > 0;
|
||||||
|
const percent = maxBoxBytes ? Math.min(100, Math.round((used / maxBoxBytes) * 100)) : 0;
|
||||||
|
document.querySelector(".upload-quota")?.classList.toggle("is-quota-warning", overQuota || overFile);
|
||||||
|
if (el.boxSpaceText) el.boxSpaceText.textContent = `${formatBytes(used)}${limitText}${overQuota ? " - over quota" : ""}`;
|
||||||
|
if (el.boxSpaceBar) {
|
||||||
|
el.boxSpaceBar.style.width = `${percent}%`;
|
||||||
|
el.boxSpaceBar.classList.toggle("is-over-quota", overQuota || overFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQueueSummary() {
|
||||||
|
const count = files.length;
|
||||||
|
if (el.queueLabel) el.queueLabel.textContent = count ? `${count} file${count === 1 ? "" : "s"} selected` : "No files selected";
|
||||||
|
if (el.queueSize) el.queueSize.textContent = `${formatBytes(totalBytes())} total`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverallProgress() {
|
||||||
|
const uploadedCount = files.filter((item) => item.uploaded).length;
|
||||||
|
const percent = overallProgress();
|
||||||
|
setOverallProgress(percent >= 100 && uploadedCount < files.length ? 99 : percent);
|
||||||
|
if (percent >= 100 && files.length && !overallImpactDone) {
|
||||||
|
overallImpactDone = true;
|
||||||
|
flashProgressBar(el.overallBar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFileRow(item, index) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "upload-file-row";
|
||||||
|
row.dataset.index = String(index);
|
||||||
|
row.classList.toggle("has-thumbnail", Boolean(item.previewURL));
|
||||||
|
row.classList.toggle("is-too-large", maxFileBytes > 0 && item.file.size > maxFileBytes);
|
||||||
|
row.classList.toggle("is-working", item.loaded > 0 && !item.uploaded && !item.failed);
|
||||||
|
row.classList.toggle("is-uploaded", item.uploaded);
|
||||||
|
row.classList.toggle("is-failed", item.failed);
|
||||||
|
row.title = item.error || "";
|
||||||
|
|
||||||
|
const icon = document.createElement("img");
|
||||||
|
icon.className = "upload-file-icon";
|
||||||
|
icon.src = item.previewURL || iconForFile(item.file);
|
||||||
|
icon.alt = "";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "upload-file-name";
|
||||||
|
name.textContent = item.displayName;
|
||||||
|
name.title = item.displayName;
|
||||||
|
|
||||||
|
const size = document.createElement("span");
|
||||||
|
size.className = "upload-file-size";
|
||||||
|
size.textContent = formatBytes(item.file.size);
|
||||||
|
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "win98-button upload-file-remove";
|
||||||
|
remove.type = "button";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.dataset.remove = String(index);
|
||||||
|
remove.title = uploadLocked ? "This file cannot be removed because this upload box was already created." : "Remove file";
|
||||||
|
remove.disabled = false;
|
||||||
|
remove.setAttribute("aria-disabled", uploadLocked ? "true" : "false");
|
||||||
|
remove.dataset.disabledReason = uploadLocked ? "Files cannot be removed after the box is created. Press Clear to start another upload." : "";
|
||||||
|
|
||||||
|
const progress = document.createElement("span");
|
||||||
|
progress.className = "upload-progress";
|
||||||
|
progress.setAttribute("aria-label", `Upload progress ${Math.round(item.file.size ? (item.loaded / item.file.size) * 100 : 0)} percent`);
|
||||||
|
|
||||||
|
const progressBar = document.createElement("span");
|
||||||
|
progressBar.className = "upload-progress-bar";
|
||||||
|
progressBar.style.width = `${item.uploaded ? 100 : item.failed ? 100 : Math.max(0, Math.min(100, item.file.size ? (item.loaded / item.file.size) * 100 : 0))}%`;
|
||||||
|
progress.append(progressBar);
|
||||||
|
|
||||||
|
row.append(icon, name, size, remove, progress);
|
||||||
|
item.row = row;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles() {
|
||||||
|
if (!el.fileList) return;
|
||||||
|
el.fileList.replaceChildren();
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "upload-empty-state";
|
||||||
|
empty.textContent = uploadsEnabled
|
||||||
|
? "No files in the box yet. Drop files here, use File > Add files, or click the dropzone."
|
||||||
|
: "Guest uploads are disabled.";
|
||||||
|
el.fileList.append(empty);
|
||||||
|
} else {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
files.forEach((item, index) => fragment.append(createFileRow(item, index)));
|
||||||
|
el.fileList.append(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueueSummary();
|
||||||
|
updateQuota();
|
||||||
|
updateOverallProgress();
|
||||||
|
updateTerminal();
|
||||||
|
updateDisabledReasons();
|
||||||
|
updateCurrentStep();
|
||||||
|
}
|
||||||
237
static/js/upload/events.js
Normal file
237
static/js/upload/events.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
function runUploadAction(action) {
|
||||||
|
const actions = {
|
||||||
|
browse: () => el.fileInput?.click(),
|
||||||
|
"start-upload": () => startUpload(),
|
||||||
|
"copy-link": () => copyText("Share URL", shareUrl, shareUrl),
|
||||||
|
clear: () => confirmClearQueue(),
|
||||||
|
"toggle-delete-once": () => {
|
||||||
|
if (!el.expiry?.querySelector(`option[value="${oneTimeRetentionKey}"]`)) return;
|
||||||
|
el.expiry.value = isOneTimeDownloadSelected() ? defaultRetention : oneTimeRetentionKey;
|
||||||
|
syncZipForRetention();
|
||||||
|
saveSettings();
|
||||||
|
syncMenuChecks();
|
||||||
|
updateTerminal();
|
||||||
|
},
|
||||||
|
"random-password": () => randomPassword(),
|
||||||
|
"random-box-name": () => randomBoxName(),
|
||||||
|
"clear-password": () => {
|
||||||
|
if (!el.password || uploadLocked) return;
|
||||||
|
el.password.value = "";
|
||||||
|
saveSettings();
|
||||||
|
updateTerminal();
|
||||||
|
},
|
||||||
|
"toggle-page": () => {
|
||||||
|
if (!el.downloadPage || uploadLocked) return;
|
||||||
|
el.downloadPage.checked = !el.downloadPage.checked;
|
||||||
|
saveSettings();
|
||||||
|
syncMenuChecks();
|
||||||
|
},
|
||||||
|
help: () => openDoc("faq"),
|
||||||
|
"side-help": () => {
|
||||||
|
openDoc("faq");
|
||||||
|
showToast("Terminal help opened. Copy the command and feed it files.");
|
||||||
|
},
|
||||||
|
"coming-soon": () => showToast("Coming Soon, not implemented just yet."),
|
||||||
|
"fake-close": () => showToast("Close button denied. The upload window is staying open.", "warning"),
|
||||||
|
minimize: () => showToast("Minimize requested. WarpBox stays visible so your queue is safe."),
|
||||||
|
"toggle-fit": () => {
|
||||||
|
document.body.classList.toggle("fit-window");
|
||||||
|
showToast("Maximize requested. The pixel rectangle feels important now.");
|
||||||
|
},
|
||||||
|
"side-close": () => showToast("Box Options refuses to leave. Settings stay visible."),
|
||||||
|
"side-folder-close": () => showToast("The folder window saw that click and chose denial."),
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[action]?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (announceDisabledReason(event)) return;
|
||||||
|
|
||||||
|
const menuButton = event.target.closest(".menu-button");
|
||||||
|
if (menuButton) {
|
||||||
|
const item = menuButton.closest(".menu-item");
|
||||||
|
const isOpen = item.classList.contains("is-open");
|
||||||
|
closeMenus();
|
||||||
|
item.classList.toggle("is-open", !isOpen);
|
||||||
|
menuButton.setAttribute("aria-expanded", String(!isOpen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = event.target.closest("[data-action]")?.dataset.action;
|
||||||
|
if (action) {
|
||||||
|
closeMenus();
|
||||||
|
runUploadAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = event.target.closest("[data-doc]")?.dataset.doc;
|
||||||
|
if (doc) {
|
||||||
|
openDoc(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = event.target.closest("[data-remove]");
|
||||||
|
if (remove) {
|
||||||
|
removeFile(Number(remove.dataset.remove));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.id === "duplicate-append") appendPendingDuplicates();
|
||||||
|
if (event.target.id === "duplicate-skip") {
|
||||||
|
pendingDuplicateFiles = [];
|
||||||
|
closeDoc();
|
||||||
|
showToast("Duplicate files skipped.");
|
||||||
|
}
|
||||||
|
if (event.target.id === "confirm-clear-yes") {
|
||||||
|
closeDoc();
|
||||||
|
clearQueue();
|
||||||
|
}
|
||||||
|
if (event.target.id === "confirm-clear-no" || event.target.id === "fallback-close") closeDoc();
|
||||||
|
|
||||||
|
if (!event.target.closest(".menu-item")) {
|
||||||
|
closeMenus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", (event) => {
|
||||||
|
announceDisabledReason(event);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.querySelectorAll(".menu-item").forEach((item) => {
|
||||||
|
item.addEventListener("mouseenter", () => {
|
||||||
|
if (!document.querySelector(".menu-item.is-open")) return;
|
||||||
|
closeMenus();
|
||||||
|
item.classList.add("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el.fileInput?.addEventListener("change", () => addFiles(el.fileInput.files));
|
||||||
|
|
||||||
|
[el.dropSurface, el.dropzone].filter(Boolean).forEach((target) => {
|
||||||
|
target.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
el.dropzone?.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
target.addEventListener("dragleave", () => el.dropzone?.classList.remove("is-dragging"));
|
||||||
|
target.addEventListener("drop", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
el.dropzone?.classList.remove("is-dragging");
|
||||||
|
addFiles(event.dataTransfer.files);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
el.dropzone?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
el.fileInput?.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el.form?.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
startUpload();
|
||||||
|
});
|
||||||
|
|
||||||
|
el.copyButton?.addEventListener("click", () => copyText("Share URL", shareUrl, shareUrl));
|
||||||
|
el.copyCurlButton?.addEventListener("click", () => copyText("cURL command", getCurlCommand({ full: true })));
|
||||||
|
el.docPopupClose?.addEventListener("click", closeDoc);
|
||||||
|
el.modalBackdrop?.addEventListener("click", closeDoc);
|
||||||
|
|
||||||
|
el.maxViews?.addEventListener("wheel", (event) => {
|
||||||
|
if (el.maxViews.disabled || el.maxViews.readOnly) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.deltaY < 0 ? 1 : -1;
|
||||||
|
const modifier = event.ctrlKey && event.shiftKey ? 50 : event.shiftKey ? 15 : event.ctrlKey ? 5 : 1;
|
||||||
|
const min = Number.parseInt(el.maxViews.min || "1", 10);
|
||||||
|
const max = Number.parseInt(el.maxViews.max || "9999", 10);
|
||||||
|
const current = Number.parseInt(el.maxViews.value || String(min), 10);
|
||||||
|
el.maxViews.value = String(Math.max(min, Math.min(max, current + (delta * modifier))));
|
||||||
|
saveSettings();
|
||||||
|
updateTerminal();
|
||||||
|
});
|
||||||
|
|
||||||
|
el.apiKeyInput?.addEventListener("keydown", (event) => {
|
||||||
|
const allowed = event.ctrlKey || event.metaKey || event.altKey || [
|
||||||
|
"Tab",
|
||||||
|
"Shift",
|
||||||
|
"Control",
|
||||||
|
"Alt",
|
||||||
|
"Meta",
|
||||||
|
"Escape",
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"PageUp",
|
||||||
|
"PageDown",
|
||||||
|
].includes(event.key);
|
||||||
|
if (allowed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
showToast("Only pasting the API key is supported.", "warning");
|
||||||
|
setStatus("Only pasting the API key is supported");
|
||||||
|
});
|
||||||
|
|
||||||
|
el.apiKeyInput?.addEventListener("paste", () => {
|
||||||
|
setTimeout(validateApiKeyField, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
[el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean).forEach((control) => {
|
||||||
|
control.addEventListener("input", () => {
|
||||||
|
if (control === el.boxName) syncSlugFromName();
|
||||||
|
if (control === el.customSlug) {
|
||||||
|
const clean = sanitizeSlugInput(el.customSlug.value);
|
||||||
|
if (el.customSlug.value !== clean) el.customSlug.value = clean;
|
||||||
|
el.customSlug.dataset.auto = "false";
|
||||||
|
}
|
||||||
|
if (control === el.apiKeyInput) validateApiKeyField();
|
||||||
|
saveSettings();
|
||||||
|
updateTerminal();
|
||||||
|
});
|
||||||
|
control.addEventListener("change", () => {
|
||||||
|
if (control === el.expiry) syncZipForRetention();
|
||||||
|
if (control === el.apiKeyMode) syncApiKeyField();
|
||||||
|
saveSettings();
|
||||||
|
syncMenuChecks();
|
||||||
|
updateTerminal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeDoc();
|
||||||
|
closeMenus();
|
||||||
|
}
|
||||||
|
if (event.key === "F1") {
|
||||||
|
event.preventDefault();
|
||||||
|
openDoc("faq");
|
||||||
|
}
|
||||||
|
if (event.ctrlKey && !event.shiftKey && !event.altKey) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === "o") {
|
||||||
|
event.preventDefault();
|
||||||
|
el.fileInput?.click();
|
||||||
|
}
|
||||||
|
if (key === "u") {
|
||||||
|
event.preventDefault();
|
||||||
|
startUpload();
|
||||||
|
}
|
||||||
|
if (key === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
copyText("cURL command", getCurlCommand({ full: true }));
|
||||||
|
}
|
||||||
|
if (key === "l") {
|
||||||
|
event.preventDefault();
|
||||||
|
copyText("Share URL", shareUrl, shareUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
files.forEach((item) => {
|
||||||
|
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
static/js/upload/files.js
Normal file
108
static/js/upload/files.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
function duplicateFileReport(incoming = []) {
|
||||||
|
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
|
||||||
|
const duplicates = [];
|
||||||
|
const unique = [];
|
||||||
|
incoming.forEach((item) => {
|
||||||
|
const key = normalizedFileName(item.displayName);
|
||||||
|
if (used.has(key)) {
|
||||||
|
duplicates.push(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
used.add(key);
|
||||||
|
unique.push(item);
|
||||||
|
});
|
||||||
|
return { unique, duplicates };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(fileList) {
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
showToast("Guest uploads are disabled.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploadLocked) {
|
||||||
|
showToast("This box is sealed. Clear it to create a fresh upload.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const incoming = Array.from(fileList || []).map((file) => makeQueuedFile(file));
|
||||||
|
if (!incoming.length) return;
|
||||||
|
|
||||||
|
const { unique, duplicates } = duplicateFileReport(incoming);
|
||||||
|
if (unique.length) {
|
||||||
|
files.push(...unique);
|
||||||
|
setShareUrl("");
|
||||||
|
renderFiles();
|
||||||
|
const warning = quotaWarningMessage();
|
||||||
|
if (warning) showWarningDialog("Quota warning", warning);
|
||||||
|
}
|
||||||
|
if (duplicates.length) showDuplicateDialog(duplicates);
|
||||||
|
|
||||||
|
if (unique.length) setStatus(`${unique.length} file${unique.length === 1 ? "" : "s"} added to queue`);
|
||||||
|
if (duplicates.length && !unique.length) setStatus(`${duplicates.length} duplicate file${duplicates.length === 1 ? "" : "s"} need your choice`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDuplicateDialog(duplicates) {
|
||||||
|
pendingDuplicateFiles = duplicates;
|
||||||
|
const list = duplicates.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)}</span></li>`).join("");
|
||||||
|
showTemplatePopup("Duplicate file names", "duplicate", { list })
|
||||||
|
.then(() => document.querySelector("#duplicate-append")?.focus());
|
||||||
|
showToast("Duplicate names found. Choose skip or append numbers.", "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPendingDuplicates() {
|
||||||
|
if (!pendingDuplicateFiles.length) return;
|
||||||
|
const used = new Set(files.map((item) => normalizedFileName(item.displayName)));
|
||||||
|
pendingDuplicateFiles.forEach((item) => {
|
||||||
|
item.displayName = nextIncrementedFileName(item.displayName, used);
|
||||||
|
files.push(item);
|
||||||
|
});
|
||||||
|
const count = pendingDuplicateFiles.length;
|
||||||
|
pendingDuplicateFiles = [];
|
||||||
|
closeDoc();
|
||||||
|
setShareUrl("");
|
||||||
|
renderFiles();
|
||||||
|
showToast("Duplicate files added with numbered names.", "info");
|
||||||
|
setStatus(`${count} duplicate file${count === 1 ? "" : "s"} added with numbered names`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index) {
|
||||||
|
if (uploadLocked) {
|
||||||
|
showToast("Box already created. Clear it before editing the queue.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [removed] = files.splice(index, 1);
|
||||||
|
if (removed?.previewURL) URL.revokeObjectURL(removed.previewURL);
|
||||||
|
setShareUrl("");
|
||||||
|
renderFiles();
|
||||||
|
setStatus("File removed from queue");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue() {
|
||||||
|
files.forEach((item) => {
|
||||||
|
if (item.previewURL) URL.revokeObjectURL(item.previewURL);
|
||||||
|
});
|
||||||
|
files = [];
|
||||||
|
pendingDuplicateFiles = [];
|
||||||
|
uploadLocked = false;
|
||||||
|
completedImpactKeys = new Set();
|
||||||
|
overallImpactDone = false;
|
||||||
|
stopStatusAnimation();
|
||||||
|
setBoxOptionsLocked(false);
|
||||||
|
setShareUrl("");
|
||||||
|
if (el.fileInput) {
|
||||||
|
el.fileInput.value = "";
|
||||||
|
el.fileInput.disabled = !uploadsEnabled;
|
||||||
|
}
|
||||||
|
el.dropzone?.classList.remove("is-locked");
|
||||||
|
renderFiles();
|
||||||
|
setStatus(uploadsEnabled ? "Queue cleared" : "Guest uploads are disabled");
|
||||||
|
showToast("Queue cleared.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmClearQueue() {
|
||||||
|
if (!files.length && !shareUrl) {
|
||||||
|
showToast("Nothing to clear.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showTemplatePopup("Clear WarpBox?", "clear")
|
||||||
|
.then(() => document.querySelector("#confirm-clear-no")?.focus());
|
||||||
|
}
|
||||||
192
static/js/upload/options.js
Normal file
192
static/js/upload/options.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
function isOneTimeDownloadSelected() {
|
||||||
|
return el.expiry?.value === oneTimeRetentionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncZipForRetention() {
|
||||||
|
if (!el.allowZip) return;
|
||||||
|
if (isOneTimeDownloadSelected()) {
|
||||||
|
el.allowZip.checked = true;
|
||||||
|
el.allowZip.disabled = true;
|
||||||
|
} else if (!uploadLocked) {
|
||||||
|
el.allowZip.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBoxOptionsLocked(locked) {
|
||||||
|
const controls = [el.expiry, el.password, el.maxViews, el.boxName, el.customSlug, el.downloadPage, el.allowZip, el.allowPreview, el.keepFilenames, el.privateBox, el.apiKeyMode, el.apiKeyInput].filter(Boolean);
|
||||||
|
el.optionsForm?.classList.toggle("is-locked", locked);
|
||||||
|
controls.forEach((control) => {
|
||||||
|
control.dataset.disabledReason = locked ? "Box Options are locked because this box was already created. Press Clear to start another upload." : "";
|
||||||
|
if (control.tagName === "INPUT" && !["checkbox", "radio", "file"].includes(control.type)) {
|
||||||
|
control.readOnly = locked;
|
||||||
|
} else {
|
||||||
|
control.disabled = locked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (el.password) el.password.type = locked ? "password" : "text";
|
||||||
|
if (!locked) {
|
||||||
|
syncZipForRetention();
|
||||||
|
syncApiKeyField();
|
||||||
|
}
|
||||||
|
updateDisabledReasons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisabledReasons() {
|
||||||
|
if (el.startButton) {
|
||||||
|
let reason = "";
|
||||||
|
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
|
||||||
|
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
|
||||||
|
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
|
||||||
|
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
|
||||||
|
el.startButton.disabled = false;
|
||||||
|
el.startButton.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||||
|
el.startButton.dataset.disabledReason = reason;
|
||||||
|
el.startButton.title = reason;
|
||||||
|
}
|
||||||
|
if (el.fileInput) {
|
||||||
|
el.fileInput.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||||
|
}
|
||||||
|
if (el.dropzone) {
|
||||||
|
el.dropzone.dataset.disabledReason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-action="start-upload"]').forEach((button) => {
|
||||||
|
const reason = el.startButton?.dataset.disabledReason || "";
|
||||||
|
button.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||||
|
button.dataset.disabledReason = reason;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-action="browse"]').forEach((button) => {
|
||||||
|
const reason = uploadLocked ? "The current box is sealed after upload. Press Clear to start a new box." : (!uploadsEnabled ? "Guest uploads are disabled." : "");
|
||||||
|
button.setAttribute("aria-disabled", reason ? "true" : "false");
|
||||||
|
button.dataset.disabledReason = reason;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-action="copy-link"]').forEach((button) => {
|
||||||
|
button.setAttribute("aria-disabled", shareUrl ? "false" : "true");
|
||||||
|
button.dataset.disabledReason = shareUrl ? "" : "There is no share URL yet. Start an upload first.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
const apiKey = el.apiKeyMode?.checked && validApiKey(el.apiKeyInput?.value || "") ? el.apiKeyInput.value.trim() : "";
|
||||||
|
const settings = {
|
||||||
|
maxViews: el.maxViews?.value || "",
|
||||||
|
allowPreview: Boolean(el.allowPreview?.checked),
|
||||||
|
keepFilenames: Boolean(el.keepFilenames?.checked),
|
||||||
|
privateBox: Boolean(el.privateBox?.checked),
|
||||||
|
apiKeyMode: Boolean(el.apiKeyMode?.checked),
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
let settings = {};
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||||
|
} catch (_) {}
|
||||||
|
if (el.maxViews) el.maxViews.value = settings.maxViews || "";
|
||||||
|
if (el.allowPreview) el.allowPreview.checked = settings.allowPreview !== false;
|
||||||
|
if (el.keepFilenames) el.keepFilenames.checked = settings.keepFilenames !== false;
|
||||||
|
if (el.privateBox) el.privateBox.checked = Boolean(settings.privateBox);
|
||||||
|
if (el.apiKeyMode) el.apiKeyMode.checked = Boolean(settings.apiKeyMode);
|
||||||
|
if (el.apiKeyInput) el.apiKeyInput.value = validApiKey(settings.apiKey || "") ? settings.apiKey : "";
|
||||||
|
syncZipForRetention();
|
||||||
|
syncApiKeyField();
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMenuChecks() {
|
||||||
|
updateDisabledReasons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncApiKeyField() {
|
||||||
|
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
|
||||||
|
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
|
||||||
|
if (el.apiKeyInput) {
|
||||||
|
el.apiKeyInput.disabled = !enabled;
|
||||||
|
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
|
||||||
|
}
|
||||||
|
validateApiKeyField();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateApiKeyField() {
|
||||||
|
if (!el.apiKeyInput || !el.apiKeyState) return;
|
||||||
|
clearTimeout(apiKeyTimer);
|
||||||
|
const wrapper = el.apiKeyInput.closest(".api-key-field");
|
||||||
|
wrapper?.classList.remove("is-checking");
|
||||||
|
|
||||||
|
if (!el.apiKeyMode?.checked) {
|
||||||
|
el.apiKeyState.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = el.apiKeyInput.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
el.apiKeyState.textContent = "waiting";
|
||||||
|
saveSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.apiKeyInput.disabled = true;
|
||||||
|
wrapper?.classList.add("is-checking");
|
||||||
|
el.apiKeyState.textContent = "checking";
|
||||||
|
apiKeyTimer = setTimeout(() => {
|
||||||
|
wrapper?.classList.remove("is-checking");
|
||||||
|
el.apiKeyInput.disabled = uploadLocked;
|
||||||
|
if (validApiKey(value)) {
|
||||||
|
el.apiKeyState.textContent = "saved locally";
|
||||||
|
saveSettings();
|
||||||
|
} else {
|
||||||
|
el.apiKeyInput.value = "";
|
||||||
|
el.apiKeyState.textContent = "invalid";
|
||||||
|
saveSettings();
|
||||||
|
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
|
||||||
|
}
|
||||||
|
}, 650);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validApiKey(value) {
|
||||||
|
return /^[A-Za-z0-9._-]{12,}$/.test(String(value || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlugInput(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, "")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSlugFromName(force = false) {
|
||||||
|
if (!el.customSlug || !el.boxName) return;
|
||||||
|
if (force || !el.customSlug.value || el.customSlug.dataset.auto === "true") {
|
||||||
|
el.customSlug.value = slugify(el.boxName.value);
|
||||||
|
el.customSlug.dataset.auto = "true";
|
||||||
|
}
|
||||||
|
saveSettings();
|
||||||
|
updateTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPassword() {
|
||||||
|
if (!el.password || uploadLocked) return;
|
||||||
|
el.password.value = `${Math.random().toString(36).slice(2, 8)}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
saveSettings();
|
||||||
|
updateTerminal();
|
||||||
|
setStatus("Generated a password");
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBoxName() {
|
||||||
|
if (!el.boxName || uploadLocked) return;
|
||||||
|
const adjectives = ["Neon", "Turbo", "Quiet", "Cosmic", "Lucky", "Midnight", "Pixel", "Rapid"];
|
||||||
|
const nouns = ["Floppy Disk", "Archive Box", "Packet Portal", "Upload Folder", "Cache Drive", "Release Bundle"];
|
||||||
|
el.boxName.value = `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`;
|
||||||
|
syncSlugFromName(true);
|
||||||
|
setStatus("Generated a local box name");
|
||||||
|
}
|
||||||
88
static/js/upload/popups.js
Normal file
88
static/js/upload/popups.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
async function copyText(kind, value, openUrl = "") {
|
||||||
|
if (!value) {
|
||||||
|
showToast(`No ${kind.toLowerCase()} yet.`, "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
showToast(`${kind} copied to clipboard.`);
|
||||||
|
setStatus(`Copied ${kind.toLowerCase()}`);
|
||||||
|
} catch (_) {
|
||||||
|
showCopyFallback(kind, value, openUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyFallback(kind, value, openUrl) {
|
||||||
|
const openLink = openUrl ? `<a class="win98-button" href="${htmlEscape(openUrl)}" target="_blank" rel="noreferrer">Open</a>` : "";
|
||||||
|
showTemplatePopup(`${kind} copy failed`, "copy-failed", {
|
||||||
|
value: htmlEscape(value),
|
||||||
|
openLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaWarningHtml(message) {
|
||||||
|
const tooLarge = oversizedFiles();
|
||||||
|
const parts = [];
|
||||||
|
if (tooLarge.length) {
|
||||||
|
parts.push("<p class=\"quota-dialog-summary\"><strong>Single-file limit exceeded.</strong> Remove these files before uploading.</p>");
|
||||||
|
parts.push(`<ol class="quota-dialog-list">${tooLarge.map((item) => `<li><strong>${htmlEscape(item.displayName)}</strong> <span>${formatBytes(item.file.size)} / max ${formatBytes(maxFileBytes)}</span></li>`).join("")}</ol>`);
|
||||||
|
}
|
||||||
|
if (isOverBoxQuota()) {
|
||||||
|
parts.push(`<p class="quota-dialog-summary"><strong>Box quota exceeded.</strong> Current total is ${formatBytes(totalBytes())}. The limit is ${formatBytes(maxBoxBytes)}. Remove ${formatBytes(totalBytes() - maxBoxBytes)} or more.</p>`);
|
||||||
|
}
|
||||||
|
if (!parts.length) parts.push(`<p>${htmlEscape(message)}</p>`);
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWarningDialog(title, message) {
|
||||||
|
showTemplatePopup(title, "warning", {
|
||||||
|
title: htmlEscape(title),
|
||||||
|
content: quotaWarningHtml(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPopup(title, html, about = false) {
|
||||||
|
window.WarpBoxUI.openPopup(title, html, {
|
||||||
|
about,
|
||||||
|
popup: el.docPopup,
|
||||||
|
title: el.docPopupTitle,
|
||||||
|
body: el.docPopupBody,
|
||||||
|
backdrop: el.modalBackdrop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDoc() {
|
||||||
|
window.WarpBoxUI.closePopup({ popup: el.docPopup, backdrop: el.modalBackdrop });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTemplatePopup(title, templateName, data = {}, about = false) {
|
||||||
|
try {
|
||||||
|
const html = await window.WBPopups.renderTemplate(templateName, data);
|
||||||
|
openPopup(title, html, about);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || `Could not load ${title}.`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupTemplateData(name) {
|
||||||
|
const data = { origin: window.location.origin };
|
||||||
|
if (name !== "dailyQuota") return data;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
boxLimit: maxBoxBytes ? formatBytes(maxBoxBytes) : "No configured limit",
|
||||||
|
boxPercent: maxBoxBytes ? Math.min(100, Math.round((totalBytes() / maxBoxBytes) * 100)) : 0,
|
||||||
|
fileLimit: maxFileBytes ? formatBytes(maxFileBytes) : "No configured limit",
|
||||||
|
filePercent: oversizedFiles().length ? 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDoc(name) {
|
||||||
|
try {
|
||||||
|
const doc = await window.WBPopups.renderDoc(name, popupTemplateData(name));
|
||||||
|
if (!doc) return;
|
||||||
|
openPopup(doc.title, doc.html, doc.about);
|
||||||
|
setStatus(`${doc.title} opened`);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || "Could not load help window.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
160
static/js/upload/state.js
Normal file
160
static/js/upload/state.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
const SETTINGS_KEY = "warpbox.upload.settings.v1";
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
form: document.querySelector("#upload-form"),
|
||||||
|
fileInput: document.querySelector("#file-upload"),
|
||||||
|
dropSurface: document.querySelector("#drop-surface"),
|
||||||
|
dropzone: document.querySelector("#dropzone"),
|
||||||
|
fileList: document.querySelector("#file-list"),
|
||||||
|
queueLabel: document.querySelector("#queue-label"),
|
||||||
|
queueSize: document.querySelector("#queue-size"),
|
||||||
|
limitHint: document.querySelector("#limit-hint"),
|
||||||
|
boxSpaceText: document.querySelector("#box-space-text"),
|
||||||
|
boxSpaceBar: document.querySelector("#box-space-bar"),
|
||||||
|
overallBar: document.querySelector("#overall-bar"),
|
||||||
|
overallPercent: document.querySelector("#overall-percent"),
|
||||||
|
shareLink: document.querySelector("#share-link"),
|
||||||
|
copyButton: document.querySelector("#copy-button"),
|
||||||
|
startButton: document.querySelector("#start-button"),
|
||||||
|
statusText: document.querySelector("#status-text"),
|
||||||
|
toast: document.querySelector("#toast"),
|
||||||
|
terminal: document.querySelector("#terminal-box"),
|
||||||
|
copyCurlButton: document.querySelector("#copy-curl-button"),
|
||||||
|
docPopup: document.querySelector("#doc-popup"),
|
||||||
|
modalBackdrop: document.querySelector("#modal-backdrop"),
|
||||||
|
docPopupTitle: document.querySelector("#doc-popup-title"),
|
||||||
|
docPopupBody: document.querySelector("#doc-popup-body"),
|
||||||
|
docPopupClose: document.querySelector("#doc-popup-close"),
|
||||||
|
expiry: document.querySelector("#expiry-select"),
|
||||||
|
password: document.querySelector("#password-input"),
|
||||||
|
optionsForm: document.querySelector("#box-options-form"),
|
||||||
|
maxViews: document.querySelector("#max-views"),
|
||||||
|
boxName: document.querySelector("#box-name"),
|
||||||
|
customSlug: document.querySelector("#custom-slug"),
|
||||||
|
downloadPage: document.querySelector("#download-page"),
|
||||||
|
allowZip: document.querySelector("#allow-zip"),
|
||||||
|
allowPreview: document.querySelector("#allow-preview"),
|
||||||
|
keepFilenames: document.querySelector("#keep-filenames"),
|
||||||
|
privateBox: document.querySelector("#private-box"),
|
||||||
|
apiKeyMode: document.querySelector("#api-key-mode"),
|
||||||
|
apiKeyInput: document.querySelector("#api-key-input"),
|
||||||
|
apiKeyRow: document.querySelector("#api-key-row"),
|
||||||
|
apiKeyState: document.querySelector("#api-key-state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
|
||||||
|
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
|
||||||
|
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
|
||||||
|
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
|
||||||
|
const oneTimeRetentionKey = "one-time";
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
let shareUrl = "";
|
||||||
|
let uploadLocked = false;
|
||||||
|
let statusTimer = null;
|
||||||
|
let pendingDuplicateFiles = [];
|
||||||
|
let apiKeyTimer = null;
|
||||||
|
let completedImpactKeys = new Set();
|
||||||
|
let overallImpactDone = false;
|
||||||
|
|
||||||
|
function numberFromDataset(value) {
|
||||||
|
const number = Number.parseInt(value || "0", 10);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlEscape = window.WarpBoxUI.htmlEscape;
|
||||||
|
|
||||||
|
function shellQuote(value) {
|
||||||
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalBytes() {
|
||||||
|
return files.reduce((sum, item) => sum + item.file.size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedBytes() {
|
||||||
|
return files.reduce((sum, item) => sum + item.loaded, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallProgress() {
|
||||||
|
const total = totalBytes();
|
||||||
|
return total ? Math.round((uploadedBytes() / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oversizedFiles() {
|
||||||
|
return maxFileBytes ? files.filter((item) => item.file.size > maxFileBytes) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverBoxQuota() {
|
||||||
|
return maxBoxBytes ? totalBytes() > maxBoxBytes : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasQuotaError() {
|
||||||
|
return isOverBoxQuota() || oversizedFiles().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedFileName(name) {
|
||||||
|
return String(name || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitNameForIncrement(name) {
|
||||||
|
const value = String(name || "file");
|
||||||
|
const dot = value.lastIndexOf(".");
|
||||||
|
if (dot > 0 && dot < value.length - 1) return [value.slice(0, dot), value.slice(dot)];
|
||||||
|
return [value, ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextIncrementedFileName(name, usedNames) {
|
||||||
|
const [base, ext] = splitNameForIncrement(name);
|
||||||
|
let index = 2;
|
||||||
|
let candidate = `${base} (${index})${ext}`;
|
||||||
|
while (usedNames.has(normalizedFileName(candidate))) {
|
||||||
|
index += 1;
|
||||||
|
candidate = `${base} (${index})${ext}`;
|
||||||
|
}
|
||||||
|
usedNames.add(normalizedFileName(candidate));
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQueuedFile(file, displayName = file.name) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
displayName,
|
||||||
|
loaded: 0,
|
||||||
|
uploaded: false,
|
||||||
|
failed: false,
|
||||||
|
error: "",
|
||||||
|
row: null,
|
||||||
|
boxID: "",
|
||||||
|
boxFile: null,
|
||||||
|
previewURL: file.type?.startsWith("image/") ? URL.createObjectURL(file) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconForFile(file) {
|
||||||
|
const filename = file.name || "";
|
||||||
|
const mimeType = file.type || "";
|
||||||
|
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
|
||||||
|
|
||||||
|
if (extension === ".exe") return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png";
|
||||||
|
if (mimeType.startsWith("image/")) return "/static/img/sprites/bitmap.png";
|
||||||
|
if (mimeType.startsWith("video/") || mimeType.startsWith("audio/")) return "/static/img/icons/netshow_notransm-1.png";
|
||||||
|
if (mimeType.startsWith("text/") || extension === ".md") return "/static/img/sprites/notepad_file-1.png";
|
||||||
|
if (mimeType.includes("zip") || mimeType.includes("compressed") || [".rar", ".7z", ".tar", ".gz"].includes(extension)) return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png";
|
||||||
|
if ([".ttf", ".otf", ".woff", ".woff2"].includes(extension)) return "/static/img/sprites/font.png";
|
||||||
|
if (extension === ".pdf") return "/static/img/sprites/journal.png";
|
||||||
|
if ([".html", ".css", ".js"].includes(extension)) return "/static/img/sprites/frame_web-0.png";
|
||||||
|
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png";
|
||||||
|
}
|
||||||
22
static/js/upload/terminal.js
Normal file
22
static/js/upload/terminal.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
function getCurlCommand({ full = true } = {}) {
|
||||||
|
const args = [];
|
||||||
|
const selectedFiles = files.length ? files : [{ displayName: "build.zip" }];
|
||||||
|
const previewLimit = full ? selectedFiles.length : 4;
|
||||||
|
selectedFiles.slice(0, previewLimit).forEach((item) => args.push(` -F ${shellQuote(`files=@${item.displayName}`)}`));
|
||||||
|
const hiddenFileCount = !full && selectedFiles.length > previewLimit ? selectedFiles.length - previewLimit : 0;
|
||||||
|
args.push(` -F ${shellQuote(`retention=${el.expiry?.value || defaultRetention}`)}`);
|
||||||
|
if (el.password?.value) args.push(` -F ${shellQuote("password=YOUR_PASSWORD")}`);
|
||||||
|
if (el.allowZip && !el.allowZip.checked) args.push(` -F ${shellQuote("allow_zip=false")}`);
|
||||||
|
|
||||||
|
const commandLines = ["curl"];
|
||||||
|
if (el.apiKeyMode?.checked) commandLines.push(` -H ${shellQuote("Authorization: Bearer YOUR_API_KEY")}`);
|
||||||
|
commandLines.push(...args, ` ${window.location.origin}/upload`);
|
||||||
|
const command = commandLines.join(" \\\n");
|
||||||
|
return hiddenFileCount ? `${command}\n# and ${hiddenFileCount} other files included when copying` : command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTerminal() {
|
||||||
|
if (!el.terminal) return;
|
||||||
|
const command = getCurlCommand({ full: false });
|
||||||
|
el.terminal.innerHTML = `<span class="terminal-muted">warpbox@cli</span>:~$ ${htmlEscape(command)}`;
|
||||||
|
}
|
||||||
85
static/js/upload/upload-flow.js
Normal file
85
static/js/upload/upload-flow.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
async function startUpload() {
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
showToast("Guest uploads are disabled.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uploadLocked) {
|
||||||
|
showToast("Upload already started. Press Clear to create another box.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!files.length) {
|
||||||
|
showWarningDialog("No files selected", "There are no files selected. Please select files to upload.");
|
||||||
|
showToast("No files selected. Please select files to upload.", "warning");
|
||||||
|
setStatus("No files selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasQuotaError()) {
|
||||||
|
showWarningDialog("Over maximum upload size", quotaWarningMessage() || "Over maximum upload size.");
|
||||||
|
showToast("Over maximum upload size.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadLocked = true;
|
||||||
|
setBoxOptionsLocked(true);
|
||||||
|
if (el.fileInput) el.fileInput.disabled = true;
|
||||||
|
el.dropzone?.classList.add("is-locked");
|
||||||
|
setShareUrl("");
|
||||||
|
files.forEach((item) => {
|
||||||
|
item.loaded = 0;
|
||||||
|
item.uploaded = false;
|
||||||
|
item.failed = false;
|
||||||
|
item.error = "";
|
||||||
|
});
|
||||||
|
completedImpactKeys = new Set();
|
||||||
|
overallImpactDone = false;
|
||||||
|
renderFiles();
|
||||||
|
|
||||||
|
let completedCount = 0;
|
||||||
|
const totalCount = files.length;
|
||||||
|
const statusPrefix = () => `${completedCount}/${totalCount}`;
|
||||||
|
setStatus(`${statusPrefix()} Uploading.`);
|
||||||
|
animateUploadStatus(statusPrefix);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const box = await createBox();
|
||||||
|
setShareUrl(box.box_url);
|
||||||
|
files.forEach((item, index) => {
|
||||||
|
item.boxID = box.box_id;
|
||||||
|
item.boxFile = box.files[index];
|
||||||
|
item.displayName = item.boxFile?.name || item.displayName;
|
||||||
|
const icon = item.row?.querySelector(".upload-file-icon");
|
||||||
|
if (icon && item.boxFile?.thumbnail_path) {
|
||||||
|
item.row.classList.add("has-thumbnail");
|
||||||
|
icon.src = item.boxFile.thumbnail_path;
|
||||||
|
} else if (icon && item.boxFile?.icon_path && !item.previewURL) {
|
||||||
|
icon.src = item.boxFile.icon_path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(files.map((item) => uploadFile(item, () => { completedCount += 1; })));
|
||||||
|
stopStatusAnimation();
|
||||||
|
|
||||||
|
const failedCount = results.filter((result) => result.status === "rejected").length;
|
||||||
|
if (failedCount > 0) {
|
||||||
|
setStatus(`${completedCount}/${totalCount} uploaded, ${failedCount} failed`);
|
||||||
|
showToast(`${failedCount} file${failedCount === 1 ? "" : "s"} failed. The share URL contains the successful files.`, "error");
|
||||||
|
renderFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverallProgress(100);
|
||||||
|
setStatus(`${completedCount}/${totalCount} uploaded. Share URL created. Press Clear to start another upload.`);
|
||||||
|
showToast("Upload complete. Share URL created.");
|
||||||
|
renderFiles();
|
||||||
|
} catch (error) {
|
||||||
|
stopStatusAnimation();
|
||||||
|
uploadLocked = false;
|
||||||
|
setBoxOptionsLocked(false);
|
||||||
|
if (el.fileInput) el.fileInput.disabled = !uploadsEnabled;
|
||||||
|
el.dropzone?.classList.remove("is-locked");
|
||||||
|
setShareUrl("");
|
||||||
|
setStatus(error.message || "Upload failed");
|
||||||
|
showToast(error.message || "Upload failed", "error");
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,17 +32,26 @@ window.WarpBoxUI = (() => {
|
|||||||
parts.backdrop?.classList.add("is-visible");
|
parts.backdrop?.classList.add("is-visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePopup(options = {}) {
|
function closePopup(options = {}) {
|
||||||
const parts = popupElements(options);
|
const parts = popupElements(options);
|
||||||
parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup");
|
parts.popup?.classList.remove("is-visible", "is-about-popup", "is-properties-popup", "is-preview-popup");
|
||||||
parts.backdrop?.classList.remove("is-visible");
|
parts.backdrop?.classList.remove("is-visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplate(template, data = {}) {
|
function htmlEscape(value) {
|
||||||
return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
|
return String(value || "")
|
||||||
return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : "";
|
.replaceAll("&", "&")
|
||||||
});
|
.replaceAll("<", "<")
|
||||||
}
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
return { toast, openPopup, closePopup, renderTemplate };
|
function renderTemplate(template, data = {}) {
|
||||||
|
return String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
|
||||||
|
return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toast, openPopup, closePopup, htmlEscape, renderTemplate };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -19,14 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="win98-panel admin-panel">
|
<div class="win98-panel admin-panel">
|
||||||
<nav class="admin-nav">
|
{{ template "admin_nav" . }}
|
||||||
<a class="win98-button" href="/admin">Admin</a>
|
|
||||||
<a class="win98-button" href="/admin/users">Users</a>
|
|
||||||
<a class="win98-button" href="/admin/tags">Tags</a>
|
|
||||||
<a class="win98-button" href="/admin/settings">Settings</a>
|
|
||||||
<span class="admin-spacer"></span>
|
|
||||||
<span>{{ .CurrentUser }}</span>
|
|
||||||
</nav>
|
|
||||||
<div class="admin-summary">
|
<div class="admin-summary">
|
||||||
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
|
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
|
||||||
<span class="win98-panel">Storage: {{ .TotalStorage }}</span>
|
<span class="win98-panel">Storage: {{ .TotalStorage }}</span>
|
||||||
|
|||||||
11
templates/admin_nav.html
Normal file
11
templates/admin_nav.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{{ define "admin_nav" }}
|
||||||
|
<nav class="admin-nav">
|
||||||
|
{{ if ne .AdminSection "dashboard" }}<a class="win98-button" href="/admin">Admin</a>{{ end }}
|
||||||
|
{{ if ne .AdminSection "boxes" }}<a class="win98-button" href="/admin/boxes">Boxes</a>{{ end }}
|
||||||
|
{{ if ne .AdminSection "users" }}<a class="win98-button" href="/admin/users">Users</a>{{ end }}
|
||||||
|
{{ if ne .AdminSection "tags" }}<a class="win98-button" href="/admin/tags">Tags</a>{{ end }}
|
||||||
|
{{ if ne .AdminSection "settings" }}<a class="win98-button" href="/admin/settings">Settings</a>{{ end }}
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<span>{{ .CurrentUser }}</span>
|
||||||
|
</nav>
|
||||||
|
{{ end }}
|
||||||
@@ -19,14 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="win98-panel admin-panel">
|
<div class="win98-panel admin-panel">
|
||||||
<nav class="admin-nav">
|
{{ template "admin_nav" . }}
|
||||||
<a class="win98-button" href="/admin">Admin</a>
|
|
||||||
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
|
||||||
<a class="win98-button" href="/admin/users">Users</a>
|
|
||||||
<a class="win98-button" href="/admin/tags">Tags</a>
|
|
||||||
<span class="admin-spacer"></span>
|
|
||||||
<span>{{ .CurrentUser }}</span>
|
|
||||||
</nav>
|
|
||||||
{{ if .Error }}
|
{{ if .Error }}
|
||||||
<p class="admin-error">{{ .Error }}</p>
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -19,14 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="win98-panel admin-panel">
|
<div class="win98-panel admin-panel">
|
||||||
<nav class="admin-nav">
|
{{ template "admin_nav" . }}
|
||||||
<a class="win98-button" href="/admin">Admin</a>
|
|
||||||
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
|
||||||
<a class="win98-button" href="/admin/users">Users</a>
|
|
||||||
<a class="win98-button" href="/admin/settings">Settings</a>
|
|
||||||
<span class="admin-spacer"></span>
|
|
||||||
<span>{{ .CurrentUser }}</span>
|
|
||||||
</nav>
|
|
||||||
{{ if .Error }}
|
{{ if .Error }}
|
||||||
<p class="admin-error">{{ .Error }}</p>
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -19,14 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="win98-panel admin-panel">
|
<div class="win98-panel admin-panel">
|
||||||
<nav class="admin-nav">
|
{{ template "admin_nav" . }}
|
||||||
<a class="win98-button" href="/admin">Admin</a>
|
|
||||||
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
|
||||||
<a class="win98-button" href="/admin/tags">Tags</a>
|
|
||||||
<a class="win98-button" href="/admin/settings">Settings</a>
|
|
||||||
<span class="admin-spacer"></span>
|
|
||||||
<span>{{ .CurrentUser }}</span>
|
|
||||||
</nav>
|
|
||||||
{{ if .Error }}
|
{{ if .Error }}
|
||||||
<p class="admin-error">{{ .Error }}</p>
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -7,7 +7,19 @@
|
|||||||
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<link rel="stylesheet" href="/static/css/window.css">
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
<link rel="stylesheet" href="/static/css/upload.css">
|
<link rel="stylesheet" href="/static/css/upload/layout.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/buttons.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/panel.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/queue.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/actions.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/sidebar.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/options.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/terminal.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/folders.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/dialogs.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/components/toast.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/dialog-content.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/upload/responsive.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -238,6 +250,15 @@
|
|||||||
<script src="/static/js/warpbox-ui.js"></script>
|
<script src="/static/js/warpbox-ui.js"></script>
|
||||||
<script src="/static/js/upload-utils.js"></script>
|
<script src="/static/js/upload-utils.js"></script>
|
||||||
<script src="/static/js/upload-popups.js"></script>
|
<script src="/static/js/upload-popups.js"></script>
|
||||||
|
<script src="/static/js/upload/state.js"></script>
|
||||||
|
<script src="/static/js/upload/dom.js"></script>
|
||||||
|
<script src="/static/js/upload/files.js"></script>
|
||||||
|
<script src="/static/js/upload/api.js"></script>
|
||||||
|
<script src="/static/js/upload/upload-flow.js"></script>
|
||||||
|
<script src="/static/js/upload/options.js"></script>
|
||||||
|
<script src="/static/js/upload/terminal.js"></script>
|
||||||
|
<script src="/static/js/upload/popups.js"></script>
|
||||||
|
<script src="/static/js/upload/events.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user