Compare commits
36 Commits
698166d23d
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
| dd8dd7cdc2 | |||
| fc54f7bb86 | |||
| 42030003d3 | |||
| 25bc095412 | |||
| 54bb68642f | |||
| 9b57b2a535 | |||
| 1cf38d126d | |||
| d0aa86205f | |||
| 36d49a970e | |||
| 3844473eb3 | |||
| 5f3f63b710 | |||
| 9951cfc8b6 | |||
| b8bb75f7e0 | |||
| b0bdf798a9 | |||
| 877ac90574 | |||
| f0b723e35d | |||
| a729b641b2 | |||
| 7d70a0c2ed | |||
| 6b9f6ac291 | |||
| 0f630b9dca | |||
| 903b4eeed8 | |||
| ac6e8c591b | |||
| fb80f11e72 | |||
| e330fb04b3 | |||
| a8c0666b5a | |||
| 6035ea1eb2 | |||
| 82acaffdd8 | |||
| cb026d4fd1 | |||
| a5d6d69be0 | |||
| fc3de58b5b | |||
| 9dececcc7d | |||
| f1600faa8d | |||
| c1489d1fbb | |||
| 041a9798a7 | |||
| 2f37958c31 | |||
| cf90e08f98 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
docs/
|
||||||
|
memory-bank/
|
||||||
|
*_test.go
|
||||||
|
README.md
|
||||||
|
run.sh
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
data/
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Core service switches
|
||||||
|
WARPBOX_GUEST_UPLOADS_ENABLED=true
|
||||||
|
WARPBOX_API_ENABLED=true
|
||||||
|
WARPBOX_ZIP_DOWNLOADS_ENABLED=true
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false
|
||||||
|
|
||||||
|
# Storage and expiry limits (in MB)
|
||||||
|
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048
|
||||||
|
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096
|
||||||
|
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600
|
||||||
|
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800
|
||||||
|
|
||||||
|
# Tuning
|
||||||
|
WARPBOX_BOX_POLL_INTERVAL_MS=5000
|
||||||
|
WARPBOX_THUMBNAIL_BATCH_SIZE=10
|
||||||
|
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=30
|
||||||
|
|
||||||
|
# Data location
|
||||||
|
# For local run: ./data
|
||||||
|
# For Docker: /app/data
|
||||||
|
WARPBOX_DATA_DIR=./data
|
||||||
|
|
||||||
|
# Admin Area
|
||||||
|
WARPBOX_ADMIN_ENABLED=true
|
||||||
|
WARPBOX_ADMIN_PASSWORD=123
|
||||||
46
.gitea/workflows/publish.yml
Normal file
46
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: Build and Publish Docker Image
|
||||||
|
run-name: Publishing ${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
run: curl -fsSL https://get.docker.com | sh
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg APP_VERSION=${{ gitea.ref_name }} \
|
||||||
|
-t tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }} \
|
||||||
|
-t tea.chunkbyte.com/kato/warpbox:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: tea.chunkbyte.com
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Push Docker Image
|
||||||
|
run: |
|
||||||
|
docker push tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }}
|
||||||
|
docker push tea.chunkbyte.com/kato/warpbox:latest
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1 +1,23 @@
|
|||||||
|
# Data & Env
|
||||||
data/
|
data/
|
||||||
|
.env
|
||||||
|
docker-compose.yml
|
||||||
|
dev
|
||||||
|
|
||||||
|
# Go
|
||||||
|
bin/
|
||||||
|
vendor/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
*.prof
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
41
CODE_OF_CONDUCT.md
Normal file
41
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Expected Conduct
|
||||||
|
|
||||||
|
- Treat contributors and users with respect.
|
||||||
|
- Assume good intent, especially during review.
|
||||||
|
- Keep feedback specific, actionable, and focused on the work.
|
||||||
|
- Be patient with different experience levels and communication styles.
|
||||||
|
- No political opinions are allowed no matter what.
|
||||||
|
|
||||||
|
## Unacceptable Conduct
|
||||||
|
|
||||||
|
- Harassment, threats, intimidation, or stalking.
|
||||||
|
- Abusive, insulting, or demeaning comments.
|
||||||
|
- Discriminatory language or behavior.
|
||||||
|
- Publishing private information without permission.
|
||||||
|
- Sustained disruption of project discussion or review.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This code of conduct applies in project spaces, including issues, pull
|
||||||
|
requests, discussions, commits, documentation, chat, and any other official
|
||||||
|
project channel.
|
||||||
|
|
||||||
|
## Reporting
|
||||||
|
|
||||||
|
Report concerns to the maintainers.
|
||||||
|
|
||||||
|
Contact placeholder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TODO: add maintainer contact address
|
||||||
|
```
|
||||||
|
|
||||||
|
If the report involves a maintainer, send it to another trusted maintainer when
|
||||||
|
available.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Maintainers may remove comments, close threads, reject contributions, block
|
||||||
|
participants, or take other reasonable action to keep the project productive.
|
||||||
125
CONTRIBUTING.md
Normal file
125
CONTRIBUTING.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Contributing to WarpBox
|
||||||
|
|
||||||
|
WarpBox is a small Go application with server-rendered HTML, vanilla
|
||||||
|
JavaScript, static CSS, local filesystem storage, and BadgerDB metadata. Keep
|
||||||
|
changes boring, readable, and easy to review.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Go 1.23 or newer, matching `go.mod`.
|
||||||
|
- No frontend toolchain. Do not add npm, Vite, React, TypeScript, Sass,
|
||||||
|
Tailwind, or a JavaScript build step for cleanup work.
|
||||||
|
|
||||||
|
Run the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd run
|
||||||
|
```
|
||||||
|
|
||||||
|
Run on another address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd run --addr :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests and Checks
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run formatting, vet, and tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts honor `GO_BIN`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GO_BIN=/path/to/go ./check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If a command cannot run in your environment, say why and include the command
|
||||||
|
that should be run locally.
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
Use Conventional Commits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type(scope): short imperative subject
|
||||||
|
```
|
||||||
|
|
||||||
|
Types:
|
||||||
|
|
||||||
|
- `feat` user-visible feature
|
||||||
|
- `fix` bug fix
|
||||||
|
- `refactor` behavior-preserving code change
|
||||||
|
- `test` tests only
|
||||||
|
- `docs` documentation only
|
||||||
|
- `style` formatting or CSS-only visual style when behavior unchanged
|
||||||
|
- `chore` tooling, dependency, build, housekeeping
|
||||||
|
- `perf` performance change
|
||||||
|
- `ci` CI config
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep subject at 72 characters or less, preferably 50 or less.
|
||||||
|
- Use imperative mood.
|
||||||
|
- Keep one concern per commit.
|
||||||
|
- Make cleanup commits behavior preserving unless the subject says `fix`.
|
||||||
|
- Mention tests run in the PR description or commit body when useful.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs(contributing): add cleanup rules
|
||||||
|
refactor(server): split upload handlers
|
||||||
|
fix(config): reject negative expiry values
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Review Expectations
|
||||||
|
|
||||||
|
Reviews should focus on behavior, safety, and maintainability:
|
||||||
|
|
||||||
|
- Confirm routes, environment variables, API response shapes, manifest fields,
|
||||||
|
and storage layout remain compatible unless the change explicitly updates
|
||||||
|
them.
|
||||||
|
- Check that cleanup keeps behavior unchanged and is small enough to review.
|
||||||
|
- Prefer narrow helpers and clear file ownership over clever abstraction.
|
||||||
|
- Ask for tests when behavior changes or risk is not obvious.
|
||||||
|
- Call out missing checks, unclear edge cases, concurrency risks, and security
|
||||||
|
risks.
|
||||||
|
|
||||||
|
## PR Checklist
|
||||||
|
|
||||||
|
Before opening or merging a PR:
|
||||||
|
|
||||||
|
- Scope is limited to one concern.
|
||||||
|
- Runtime behavior is unchanged for cleanup PRs.
|
||||||
|
- Public routes are unchanged unless intentional.
|
||||||
|
- Environment variables are unchanged unless intentional.
|
||||||
|
- API response shapes are unchanged unless intentional.
|
||||||
|
- Manifest JSON field names are unchanged unless intentional.
|
||||||
|
- Storage directory layout is unchanged unless intentional.
|
||||||
|
- No frontend build tooling was added.
|
||||||
|
- Tests or checks run are listed.
|
||||||
|
|
||||||
|
## Coding Standards Summary
|
||||||
|
|
||||||
|
- Go: small functions, clear errors, stable exported names, no unrelated
|
||||||
|
package moves.
|
||||||
|
- JavaScript: vanilla browser scripts, no build step, explicit state ownership,
|
||||||
|
small modules when files are split.
|
||||||
|
- CSS: keep shared styles shared, page styles page-scoped, avoid duplicated
|
||||||
|
popup/window rules.
|
||||||
|
- Templates: keep server-rendered HTML simple and routes stable.
|
||||||
|
- Comments: explain behavior rules, edge cases, concurrency, security, or
|
||||||
|
product choices. Do not restate obvious code.
|
||||||
|
|
||||||
|
See [DEVELOPMENT.md](DEVELOPMENT.md) for cleanup rules.
|
||||||
183
DEVELOPMENT.md
Normal file
183
DEVELOPMENT.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# WarpBox Development Rules
|
||||||
|
|
||||||
|
This guide exists for contributors and LLM agents doing behavior-preserving
|
||||||
|
cleanup. It complements [docs/tech.md](docs/tech.md), which maps the current
|
||||||
|
implementation.
|
||||||
|
|
||||||
|
## Cleanup Principles
|
||||||
|
|
||||||
|
- Keep systems boring and obvious.
|
||||||
|
- Prefer short files grouped by one clear responsibility.
|
||||||
|
- Prefer narrow helpers over clever abstraction.
|
||||||
|
- Keep related functions physically close.
|
||||||
|
- Split files when one file mixes multiple domains.
|
||||||
|
- Avoid huge utility drawers where unrelated helpers gather.
|
||||||
|
- Do behavior-preserving cleanup before feature work.
|
||||||
|
- Use tests before and after each cleanup slice.
|
||||||
|
|
||||||
|
Cleanup is not feature work. Do not change runtime behavior unless the task
|
||||||
|
explicitly says to fix behavior.
|
||||||
|
|
||||||
|
## File Responsibility Goals
|
||||||
|
|
||||||
|
Files should not mix:
|
||||||
|
|
||||||
|
- UI and state.
|
||||||
|
- Transport and rendering.
|
||||||
|
- Validation and routing.
|
||||||
|
- Filesystem operations and business rules.
|
||||||
|
- Admin workflows and public box workflows.
|
||||||
|
|
||||||
|
When a file is large and contains multiple concerns, prefer splitting by
|
||||||
|
responsibility. Use comment regions only when a file is cohesive and splitting
|
||||||
|
would make it harder to follow.
|
||||||
|
|
||||||
|
Previously split cleanup targets:
|
||||||
|
|
||||||
|
- `static/js/app.js` now bootstraps `static/js/upload/`.
|
||||||
|
- `static/css/upload.css` now lives under `static/css/upload/` and `static/css/components/`.
|
||||||
|
- `lib/server/handlers.go` is split by handler responsibility.
|
||||||
|
- `lib/boxstore/store.go` is split by storage responsibility.
|
||||||
|
- `lib/server/admin.go` is split by admin responsibility.
|
||||||
|
- `lib/config/config.go` is split by config responsibility.
|
||||||
|
|
||||||
|
Do not refactor multiple systems in one cleanup slice.
|
||||||
|
|
||||||
|
## Comment and JSDoc Guidance
|
||||||
|
|
||||||
|
Add comments for:
|
||||||
|
|
||||||
|
- Behavior rules that are easy to break.
|
||||||
|
- Edge cases.
|
||||||
|
- Concurrency or background worker behavior.
|
||||||
|
- Security-sensitive choices.
|
||||||
|
- Non-obvious product decisions.
|
||||||
|
|
||||||
|
Avoid comments that restate code. Prefer clear names and small functions first.
|
||||||
|
Use JSDoc only when it clarifies non-obvious inputs, outputs, or side effects.
|
||||||
|
|
||||||
|
## Go Rules
|
||||||
|
|
||||||
|
- Keep public routes stable.
|
||||||
|
- Keep environment variable names stable.
|
||||||
|
- Keep API response shapes stable.
|
||||||
|
- Keep manifest JSON field names stable.
|
||||||
|
- Keep storage directory layout stable.
|
||||||
|
- Keep handler files focused on one handler category.
|
||||||
|
- Keep route registration separate from validation and business logic.
|
||||||
|
- Return clear wrapped errors when context helps debugging.
|
||||||
|
- Avoid package moves unless the cleanup slice is specifically about package
|
||||||
|
ownership.
|
||||||
|
- Run `gofmt`, `go vet`, and `go test` through `./check.sh`.
|
||||||
|
|
||||||
|
Server handler files:
|
||||||
|
|
||||||
|
- `pages.go`
|
||||||
|
- `downloads.go`
|
||||||
|
- `uploads.go`
|
||||||
|
- `box_auth.go`
|
||||||
|
- `validation.go`
|
||||||
|
- `retention.go`
|
||||||
|
|
||||||
|
Keep `lib/server/handlers.go` absent unless there is a deliberate reason to
|
||||||
|
reintroduce a cohesive handler file.
|
||||||
|
|
||||||
|
## JavaScript Rules
|
||||||
|
|
||||||
|
- Use vanilla JavaScript only.
|
||||||
|
- Do not add a build step.
|
||||||
|
- Keep browser scripts loaded directly by templates.
|
||||||
|
- Avoid new globals; centralize mutable upload state when splitting.
|
||||||
|
- Keep DOM queries/rendering separate from API calls and upload orchestration.
|
||||||
|
- Prefer an action map over long action `if` chains when cleaning event code.
|
||||||
|
- Share generic UI helpers through `static/js/warpbox-ui.js`.
|
||||||
|
- Preserve existing data attributes and template contracts unless explicitly
|
||||||
|
changing behavior.
|
||||||
|
|
||||||
|
Target upload split when that cleanup slice is chosen:
|
||||||
|
|
||||||
|
- `static/js/upload/state.js`
|
||||||
|
- `static/js/upload/dom.js`
|
||||||
|
- `static/js/upload/files.js`
|
||||||
|
- `static/js/upload/api.js`
|
||||||
|
- `static/js/upload/upload-flow.js`
|
||||||
|
- `static/js/upload/options.js`
|
||||||
|
- `static/js/upload/popups.js`
|
||||||
|
- `static/js/upload/terminal.js`
|
||||||
|
- `static/js/upload/events.js`
|
||||||
|
- `static/js/app.js` as bootstrap only
|
||||||
|
|
||||||
|
## CSS Rules
|
||||||
|
|
||||||
|
- Keep shared styles in shared files.
|
||||||
|
- Keep page-specific styles page-scoped.
|
||||||
|
- Avoid duplicated popup, toast, button, and window rules.
|
||||||
|
- Use page prefixes for page styles:
|
||||||
|
- `upload-`
|
||||||
|
- `box-`
|
||||||
|
- `admin-`
|
||||||
|
- Keep visual changes out of behavior-preserving cleanup unless the cleanup
|
||||||
|
slice is CSS-only.
|
||||||
|
- Preserve template class names unless the same slice updates every use.
|
||||||
|
|
||||||
|
Target CSS split when that cleanup slice is chosen:
|
||||||
|
|
||||||
|
- `base.css`
|
||||||
|
- `window.css`
|
||||||
|
- `components/buttons.css`
|
||||||
|
- `components/popups.css`
|
||||||
|
- `components/toast.css`
|
||||||
|
- `upload/layout.css`
|
||||||
|
- `upload/queue.css`
|
||||||
|
- `upload/options.css`
|
||||||
|
- `upload/dialogs.css`
|
||||||
|
- `upload/responsive.css`
|
||||||
|
- `box.css`
|
||||||
|
- `admin.css`
|
||||||
|
|
||||||
|
## Template Rules
|
||||||
|
|
||||||
|
- Keep server-rendered HTML simple.
|
||||||
|
- Do not rename public routes during cleanup.
|
||||||
|
- Do not change form field names or data attributes unless the matching Go and
|
||||||
|
JavaScript code changes in the same slice.
|
||||||
|
- Keep static CSS and JS loading explicit.
|
||||||
|
- Avoid hidden behavior changes through template conditionals.
|
||||||
|
|
||||||
|
Current loading model:
|
||||||
|
|
||||||
|
- Go loads templates from `templates/*.html`.
|
||||||
|
- Gin serves `/static` from `./static` with gzip middleware.
|
||||||
|
- Templates link CSS directly from `/static/css/...`.
|
||||||
|
- Templates load browser JavaScript directly from `/static/js/...`.
|
||||||
|
- There is no JavaScript or CSS build step.
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Inspect repository structure before editing.
|
||||||
|
- Identify relevant files for the current cleanup slice.
|
||||||
|
- Identify how JavaScript and CSS are loaded before frontend cleanup.
|
||||||
|
- Identify available test/check commands before editing.
|
||||||
|
- Summarize the intended change before editing.
|
||||||
|
- Make the smallest useful change.
|
||||||
|
- Do not rename public routes.
|
||||||
|
- Do not rename environment variables.
|
||||||
|
- Do not change API response shapes.
|
||||||
|
- Do not change manifest JSON field names.
|
||||||
|
- Do not change storage directory layout.
|
||||||
|
- Do not add frontend tooling during cleanup.
|
||||||
|
- Do not rewrite working code just to make it look different.
|
||||||
|
- Do not mix unrelated cleanup areas in one change.
|
||||||
|
- Do not claim tests passed unless they actually ran.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
For each cleanup slice:
|
||||||
|
|
||||||
|
- Change scope matches one cleanup area.
|
||||||
|
- Behavior is unchanged unless the slice is explicitly a fix.
|
||||||
|
- Files have clearer responsibility than before.
|
||||||
|
- Comments explain only non-obvious rules or risks.
|
||||||
|
- Tests or checks were run and recorded.
|
||||||
|
- Any failed command is recorded with the exact reason.
|
||||||
|
- Next cleanup slice is clear.
|
||||||
77
Dockerfile
Normal file
77
Dockerfile
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Stage 1: Build
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go modules and download dependencies
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
|
# Copy source code and static assets
|
||||||
|
COPY cmd/ cmd/
|
||||||
|
COPY lib/ lib/
|
||||||
|
COPY static/ static/
|
||||||
|
COPY templates/ templates/
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
RUN apk add \
|
||||||
|
--no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
wget
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/warpbox .
|
||||||
|
|
||||||
|
# Copy static assets
|
||||||
|
COPY --from=builder /build/static/ static/
|
||||||
|
COPY --from=builder /build/templates/ templates/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data/uploads /app/data/db && chown -R warpbox:warpbox /app/data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER warpbox
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV WARPBOX_DATA_DIR=/app/data \
|
||||||
|
WARPBOX_GUEST_UPLOADS_ENABLED=true \
|
||||||
|
WARPBOX_API_ENABLED=true \
|
||||||
|
WARPBOX_ZIP_DOWNLOADS_ENABLED=true \
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOADS_ENABLED=true \
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
|
||||||
|
WARPBOX_ADMIN_ENABLED=true \
|
||||||
|
WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
|
||||||
|
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
|
||||||
|
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
|
||||||
|
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
|
||||||
|
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
|
||||||
|
WARPBOX_THUMBNAIL_BATCH_SIZE=10 \
|
||||||
|
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=30
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
|
||||||
|
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
|
CMD ["./warpbox", "run", "--addr", ":8080"]
|
||||||
190
LICENSE
Normal file
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.
|
||||||
213
README.md
213
README.md
@@ -0,0 +1,213 @@
|
|||||||
|
# WarpBox
|
||||||
|
|
||||||
|
WarpBox is a small, self-hosted file sharing app with temporary upload boxes,
|
||||||
|
simple download links, optional passwords, ZIP downloads, generated image
|
||||||
|
thumbnails, and a very deliberate retro desktop mood.
|
||||||
|
|
||||||
|
It is meant to feel quick: pick files, choose how long the box should live,
|
||||||
|
upload, and share the link.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User[Person in browser]
|
||||||
|
UI[WarpBox UI]
|
||||||
|
API[Go HTTP server]
|
||||||
|
Manifest[(Box manifest JSON)]
|
||||||
|
Files[(Uploaded files)]
|
||||||
|
Thumbs[(Thumbnail JPEGs)]
|
||||||
|
DB[(BadgerDB metadata)]
|
||||||
|
|
||||||
|
User --> UI
|
||||||
|
UI -->|create box / upload / poll status| API
|
||||||
|
API --> Manifest
|
||||||
|
API --> Files
|
||||||
|
API --> DB
|
||||||
|
Files -->|download files or build ZIP| API
|
||||||
|
Thumbs -->|preview URLs| UI
|
||||||
|
Files -->|scan image files| Thumbs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multi-file uploads through a browser UI.
|
||||||
|
- Temporary boxes with configurable retention choices.
|
||||||
|
- Optional password protection per box.
|
||||||
|
- Individual file downloads or a single ZIP download.
|
||||||
|
- One-time download mode for ZIP-only handoff.
|
||||||
|
- Background thumbnails for image files.
|
||||||
|
- Plain filesystem storage, with JSON manifests next to uploaded files.
|
||||||
|
- Local BadgerDB metadata store for users, tags, sessions, and settings.
|
||||||
|
- No external database service required.
|
||||||
|
|
||||||
|
## How It Fits Together
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Browser[Browser UI]
|
||||||
|
Server[Go HTTP server]
|
||||||
|
Manifest[Box manifest JSON]
|
||||||
|
Files[Uploaded files]
|
||||||
|
Thumbs[Generated thumbnails]
|
||||||
|
DB[(BadgerDB metadata)]
|
||||||
|
|
||||||
|
Browser -->|POST /box, uploads, status polls| Server
|
||||||
|
Server --> Manifest
|
||||||
|
Server --> Files
|
||||||
|
Server --> Thumbs
|
||||||
|
Server --> DB
|
||||||
|
Thumbs -->|preview URLs| Browser
|
||||||
|
Files -->|downloads / ZIP| Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Go 1.22 or newer.
|
||||||
|
|
||||||
|
Run the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd run
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
To listen somewhere else:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd run --addr :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
WarpBox loads defaults, applies environment variables at startup, then applies
|
||||||
|
safe admin settings overrides from BadgerDB. Storage path settings remain
|
||||||
|
environment controlled.
|
||||||
|
|
||||||
|
| Variable | Default | What it does |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `WARPBOX_DATA_DIR` | `./data` | Root directory for uploads and metadata. |
|
||||||
|
| `WARPBOX_ADMIN_PASSWORD` | empty | Bootstraps the first admin when set. |
|
||||||
|
| `WARPBOX_ADMIN_USERNAME` | `admin` | Bootstrap admin username. |
|
||||||
|
| `WARPBOX_ADMIN_EMAIL` | empty | Bootstrap admin email. |
|
||||||
|
| `WARPBOX_ADMIN_ENABLED` | `auto` | Admin login mode: `auto`, `true`, or `false`. |
|
||||||
|
| `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE` | `true` | Allows safe settings overrides from `/admin/settings`. |
|
||||||
|
| `WARPBOX_ADMIN_COOKIE_SECURE` | `false` | Sets the Secure flag on admin session cookies. |
|
||||||
|
| `WARPBOX_GUEST_UPLOADS_ENABLED` | `true` | Enables guest uploads. |
|
||||||
|
| `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. |
|
||||||
|
| `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. |
|
||||||
|
| `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED` | `true` | Enables one-time download boxes. |
|
||||||
|
| `WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS` | `604800` | One-time box lifetime after uploads finish; `0` disables timed expiry. |
|
||||||
|
| `WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE` | `false` | Keeps one-time boxes alive when ZIP build/send fails before completion. |
|
||||||
|
| `WARPBOX_RENEW_ON_ACCESS_ENABLED` | `false` | Renews expiring boxes on access. |
|
||||||
|
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
|
||||||
|
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
|
||||||
|
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
|
||||||
|
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. |
|
||||||
|
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. |
|
||||||
|
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
|
||||||
|
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
|
||||||
|
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
|
||||||
|
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
|
||||||
|
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
|
||||||
|
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
||||||
|
|
||||||
|
Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WARPBOX_ADMIN_PASSWORD='change-me' \
|
||||||
|
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
|
||||||
|
WARPBOX_BOX_POLL_INTERVAL_MS=2000 \
|
||||||
|
WARPBOX_THUMBNAIL_BATCH_SIZE=20 \
|
||||||
|
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \
|
||||||
|
go run ./cmd run --addr :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `/admin/login` after startup to sign in with the bootstrap admin.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
Uploads are stored locally under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<WARPBOX_DATA_DIR>/uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
Each box gets its own directory containing the uploaded files and a
|
||||||
|
`.warpbox.json` manifest. Image thumbnails are stored inside a box-local
|
||||||
|
`.thumbnails` directory.
|
||||||
|
|
||||||
|
Persistent app metadata lives in BadgerDB under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<WARPBOX_DATA_DIR>/db/
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/uploads/
|
||||||
|
+-- <box-id>/
|
||||||
|
+-- .warpbox.json
|
||||||
|
+-- file.txt
|
||||||
|
+-- .thumbnails/
|
||||||
|
+-- <file-id>.jpg
|
||||||
|
data/db/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/ CLI entrypoint
|
||||||
|
lib/server/ HTTP handlers and server setup
|
||||||
|
lib/routing/ Route registration
|
||||||
|
lib/boxstore/ Box storage, manifests, downloads, thumbnails
|
||||||
|
lib/config/ Typed environment and runtime settings config
|
||||||
|
lib/metastore/ BadgerDB metadata store for users, tags, settings, sessions
|
||||||
|
lib/helpers/ Small shared helpers
|
||||||
|
lib/models/ Shared request/response models
|
||||||
|
templates/ Server-rendered HTML
|
||||||
|
static/css/ Stylesheets
|
||||||
|
static/js/ Browser scripts
|
||||||
|
static/img/ Icons, sprites, and backgrounds
|
||||||
|
static/fonts/ Bitmap/pixel fonts
|
||||||
|
static/cursors/ Custom cursor packs
|
||||||
|
static/popups/ HTML popup content
|
||||||
|
docs/ Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
WarpBox is intentionally simple. It uses the local filesystem for box data,
|
||||||
|
BadgerDB for app metadata, relies on generated box IDs for share links, and
|
||||||
|
keeps most behavior easy to follow from the Go handlers and the small browser
|
||||||
|
scripts.
|
||||||
|
|
||||||
|
For a short implementation overview, see [docs/tech.md](docs/tech.md).
|
||||||
|
|
||||||
|
## Docker / Podman
|
||||||
|
|
||||||
|
If you are using Podman, please pay attention in the [docker-compose.yml](./docker-compose.example.yml) example
|
||||||
|
file that has been provided, there's comments in regards to differences between the two.
|
||||||
|
|
||||||
|
|
||||||
|
When it comes to building the image, please make sure that you basically set the `--format docker` in the podman
|
||||||
|
build command, otherwise it won't have HealthChecks and other issues might arise.
|
||||||
|
|
||||||
|
Tip: Put the following in `~/.config/containers/containers.conf`
|
||||||
|
```toml
|
||||||
|
[engine]
|
||||||
|
image_default_format = "docker"
|
||||||
|
```
|
||||||
|
|
||||||
|
For just running the docker-compose.yml with docker image format:
|
||||||
|
```bash
|
||||||
|
BUILDAH_FORMAT=docker podman compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
TRADEMARK.md
Normal file
2
TRADEMARK.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
The name "WarpBox" and associated branding are not licensed under the Apache License 2.0.
|
||||||
|
You may not use them without permission.
|
||||||
19
check.sh
Executable file
19
check.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ -n "${GO_BIN:-}" ]; then
|
||||||
|
go_bin="$GO_BIN"
|
||||||
|
elif command -v go >/dev/null 2>&1; then
|
||||||
|
go_bin="$(command -v go)"
|
||||||
|
elif [ -x /home/linuxbrew/.linuxbrew/bin/go ]; then
|
||||||
|
go_bin=/home/linuxbrew/.linuxbrew/bin/go
|
||||||
|
else
|
||||||
|
echo "go not found. Set GO_BIN=/path/to/go or install Go." >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$go_bin" fmt ./...
|
||||||
|
"$go_bin" vet ./...
|
||||||
|
"$go_bin" test ./... "$@"
|
||||||
554
cmd/cmd_box.go
Normal file
554
cmd/cmd_box.go
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBoxCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "box",
|
||||||
|
Short: "Manage boxes",
|
||||||
|
Long: "Manage WarpBox upload boxes: list, view, inspect, delete, modify.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newBoxListCommand())
|
||||||
|
cmd.AddCommand(newBoxViewCommand())
|
||||||
|
cmd.AddCommand(newBoxInspectCommand())
|
||||||
|
cmd.AddCommand(newBoxDeleteCommand())
|
||||||
|
cmd.AddCommand(newBoxChangeCommand())
|
||||||
|
cmd.AddCommand(newBoxGetCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxListCommand() *cobra.Command {
|
||||||
|
var format string
|
||||||
|
var uploadRoot string
|
||||||
|
var sortBy string
|
||||||
|
var sortOrder string
|
||||||
|
var filterExpired string
|
||||||
|
var filterPassword string
|
||||||
|
var filterOneTime string
|
||||||
|
var filterSizeMin string
|
||||||
|
var filterSizeMax string
|
||||||
|
var filterCreatedAfter string
|
||||||
|
var filterCreatedBefore string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list", "view"},
|
||||||
|
Short: "List all boxes",
|
||||||
|
Long: "List all boxes with optional sorting and filtering.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
summaries, err := boxstore.ListBoxSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list boxes: %w", err)
|
||||||
|
}
|
||||||
|
if len(summaries) == 0 {
|
||||||
|
fmt.Println("No boxes found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
summaries = filterBoxes(summaries, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore)
|
||||||
|
|
||||||
|
if len(summaries) == 0 {
|
||||||
|
fmt.Println("No boxes match the given filters.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
sortBoxes(summaries, sortBy, sortOrder)
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "json":
|
||||||
|
return formatBoxSummariesJSON(summaries)
|
||||||
|
case "table", "":
|
||||||
|
return formatBoxSummariesTable(summaries)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown format: %s (use 'table' or 'json')", format)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json")
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().StringVar(&sortBy, "sort", "created", "Sort field: created, expires, size, files")
|
||||||
|
cmd.Flags().StringVar(&sortOrder, "sort-order", "desc", "Sort order: asc, desc")
|
||||||
|
cmd.Flags().StringVar(&filterExpired, "filter-expired", "", "Filter by expiry: yes, no, all")
|
||||||
|
cmd.Flags().StringVar(&filterPassword, "filter-password", "", "Filter by password: yes, no, all")
|
||||||
|
cmd.Flags().StringVar(&filterOneTime, "filter-one-time", "", "Filter by one-time: yes, no, all")
|
||||||
|
cmd.Flags().StringVar(&filterSizeMin, "filter-size-min", "", "Minimum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
|
||||||
|
cmd.Flags().StringVar(&filterSizeMax, "filter-size-max", "", "Maximum total size in bytes (e.g. 1024, 1k, 1m, 1g)")
|
||||||
|
cmd.Flags().StringVar(&filterCreatedAfter, "filter-created-after", "", "Only boxes created after this time (RFC3339)")
|
||||||
|
cmd.Flags().StringVar(&filterCreatedBefore, "filter-created-before", "", "Only boxes created before this time (RFC3339)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterBoxes(summaries []models.BoxSummary, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore string) []models.BoxSummary {
|
||||||
|
result := make([]models.BoxSummary, 0, len(summaries))
|
||||||
|
|
||||||
|
minSize, _ := parseSizeFilter(filterSizeMin)
|
||||||
|
maxSize, _ := parseSizeFilter(filterSizeMax)
|
||||||
|
createdAfter, _ := time.Parse(time.RFC3339, filterCreatedAfter)
|
||||||
|
createdBefore, _ := time.Parse(time.RFC3339, filterCreatedBefore)
|
||||||
|
|
||||||
|
for _, s := range summaries {
|
||||||
|
if filterExpired != "" && filterExpired != "all" {
|
||||||
|
match := "no"
|
||||||
|
if s.Expired {
|
||||||
|
match = "yes"
|
||||||
|
}
|
||||||
|
if match != filterExpired {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filterPassword != "" && filterPassword != "all" {
|
||||||
|
match := "no"
|
||||||
|
if s.PasswordProtected {
|
||||||
|
match = "yes"
|
||||||
|
}
|
||||||
|
if match != filterPassword {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filterOneTime != "" && filterOneTime != "all" {
|
||||||
|
match := "no"
|
||||||
|
if s.OneTimeDownload {
|
||||||
|
match = "yes"
|
||||||
|
}
|
||||||
|
if match != filterOneTime {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minSize > 0 && s.TotalSize < minSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if maxSize > 0 && s.TotalSize > maxSize {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !createdAfter.IsZero() && s.CreatedAt.Before(createdAfter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !createdBefore.IsZero() && !s.CreatedAt.Before(createdBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, s)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSizeFilter(s string) (int64, error) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
|
||||||
|
multiplier := int64(1)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, "g"):
|
||||||
|
multiplier = 1024 * 1024 * 1024
|
||||||
|
s = strings.TrimSuffix(lower, "g")
|
||||||
|
case strings.HasSuffix(lower, "m"):
|
||||||
|
multiplier = 1024 * 1024
|
||||||
|
s = strings.TrimSuffix(lower, "m")
|
||||||
|
case strings.HasSuffix(lower, "k"):
|
||||||
|
multiplier = 1024
|
||||||
|
s = strings.TrimSuffix(lower, "k")
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid size filter: %s", s)
|
||||||
|
}
|
||||||
|
return val * multiplier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortBoxes(summaries []models.BoxSummary, sortBy, sortOrder string) {
|
||||||
|
reverse := false
|
||||||
|
if strings.EqualFold(sortOrder, "desc") {
|
||||||
|
reverse = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(summaries, func(i, j int) bool {
|
||||||
|
var less bool
|
||||||
|
switch strings.ToLower(sortBy) {
|
||||||
|
case "size":
|
||||||
|
less = summaries[i].TotalSize < summaries[j].TotalSize
|
||||||
|
case "files":
|
||||||
|
less = summaries[i].FileCount < summaries[j].FileCount
|
||||||
|
case "expires":
|
||||||
|
// Boxes with no expiry go last
|
||||||
|
iZero := summaries[i].ExpiresAt.IsZero()
|
||||||
|
jZero := summaries[j].ExpiresAt.IsZero()
|
||||||
|
if iZero && jZero {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if iZero {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if jZero {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
less = summaries[i].ExpiresAt.Before(summaries[j].ExpiresAt)
|
||||||
|
case "created", "":
|
||||||
|
less = summaries[i].CreatedAt.Before(summaries[j].CreatedAt)
|
||||||
|
default:
|
||||||
|
less = summaries[i].ID < summaries[j].ID
|
||||||
|
}
|
||||||
|
if reverse {
|
||||||
|
return !less
|
||||||
|
}
|
||||||
|
return less
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxViewCommand() *cobra.Command {
|
||||||
|
var uploadRoot string
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "view",
|
||||||
|
Short: "View box summary",
|
||||||
|
Long: "View a box summary showing files, size, expiry, etc.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
boxID := args[0]
|
||||||
|
summary, err := boxstore.BoxSummary(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to view box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
if asJSON {
|
||||||
|
return formatBoxSummaryJSON(&summary)
|
||||||
|
}
|
||||||
|
printBoxSummary(&summary)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxInspectCommand() *cobra.Command {
|
||||||
|
var uploadRoot string
|
||||||
|
var full bool
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "inspect",
|
||||||
|
Short: "Inspect box manifest (raw JSON)",
|
||||||
|
Long: "Print the full box manifest as JSON. Use --full for hidden fields.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
boxID := args[0]
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
if !full {
|
||||||
|
sanitized := manifest
|
||||||
|
sanitized.PasswordHash = "[REDACTED]"
|
||||||
|
sanitized.PasswordSalt = "[REDACTED]"
|
||||||
|
sanitized.AuthToken = "[REDACTED]"
|
||||||
|
manifest = sanitized
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(manifest)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVar(&full, "full", false, "Show sensitive fields (password hash, auth token)")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON (default for inspect)")
|
||||||
|
_ = asJSON // inspect is always JSON; flag kept for consistency
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxDeleteCommand() *cobra.Command {
|
||||||
|
var uploadRoot string
|
||||||
|
var force bool
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "rm",
|
||||||
|
Aliases: []string{"del", "delete"},
|
||||||
|
Short: "Delete a box",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
boxID := args[0]
|
||||||
|
if !force {
|
||||||
|
fmt.Printf("This will permanently delete box %s and all its files.\n", boxID)
|
||||||
|
fmt.Print("Confirm (y/N): ")
|
||||||
|
var confirm string
|
||||||
|
if _, err := fmt.Scanln(&confirm); err != nil {
|
||||||
|
confirm = "n"
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(confirm)) != "y" {
|
||||||
|
if asJSON {
|
||||||
|
fmt.Println(`{"deleted": false, "reason": "aborted"}`)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := boxstore.DeleteBox(boxID); err != nil {
|
||||||
|
if asJSON {
|
||||||
|
fmt.Printf(`{"deleted": false, "error": "%s"}\n`, strings.ReplaceAll(err.Error(), `"`, `\"`))
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to delete box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if asJSON {
|
||||||
|
fmt.Printf(`{"deleted": true, "box_id": "%s"}\n`, boxID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Box %s deleted.\n", boxID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxChangeCommand() *cobra.Command {
|
||||||
|
var uploadRoot string
|
||||||
|
var retention int64
|
||||||
|
var retentionList bool
|
||||||
|
var password string
|
||||||
|
var zip bool
|
||||||
|
var oneTime bool
|
||||||
|
var renew bool
|
||||||
|
var renewSeconds int64
|
||||||
|
var asJSON bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "change",
|
||||||
|
Aliases: []string{"update", "modify"},
|
||||||
|
Short: "Change box properties",
|
||||||
|
Long: "Change box properties: retention, password, zip, one-time download, renew expiry.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
boxID := args[0]
|
||||||
|
|
||||||
|
if retentionList {
|
||||||
|
printRetentionOptions()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := gatherBoxChanges(cmd.Flags(), retention, password, zip, oneTime, renew, renewSeconds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changes) == 0 {
|
||||||
|
fmt.Println("No changes specified. Use --retention, --password, --zip, --one-time, --renew, or --retention-list.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, apply := range changes {
|
||||||
|
if err := apply(&manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||||
|
return fmt.Errorf("failed to save manifest for box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
return formatChangeResultJSON(boxID, manifest)
|
||||||
|
}
|
||||||
|
fmt.Printf("Box %s updated.\n", boxID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().Int64Var(&retention, "retention", 0, "Set retention seconds (use --retention-list for valid values)")
|
||||||
|
cmd.Flags().BoolVar(&retentionList, "retention-list", false, "List available retention options")
|
||||||
|
cmd.Flags().StringVar(&password, "password", "", "Set a new password (empty string to remove)")
|
||||||
|
cmd.Flags().BoolVar(&zip, "zip", true, "Allow ZIP downloads (default true, --zip=false to disable)")
|
||||||
|
cmd.Flags().BoolVar(&oneTime, "one-time", false, "Enable one-time download mode")
|
||||||
|
cmd.Flags().BoolVar(&renew, "renew", false, "Renew box expiry (use --renew-seconds for duration)")
|
||||||
|
cmd.Flags().Int64Var(&renewSeconds, "renew-seconds", 0, "Seconds to extend expiry by (used with --renew)")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeFunc func(*models.BoxManifest) error
|
||||||
|
|
||||||
|
func gatherBoxChanges(flags *pflag.FlagSet, retention int64, password string, zip bool, oneTime bool, renew bool, renewSeconds int64) ([]changeFunc, error) {
|
||||||
|
var changes []changeFunc
|
||||||
|
|
||||||
|
if flags.Changed("retention") {
|
||||||
|
if retention < 0 {
|
||||||
|
return nil, fmt.Errorf("retention cannot be negative")
|
||||||
|
}
|
||||||
|
changes = append(changes, func(m *models.BoxManifest) error {
|
||||||
|
if m.OneTimeDownload {
|
||||||
|
m.OneTimeDownload = false
|
||||||
|
}
|
||||||
|
m.RetentionSecs = retention
|
||||||
|
for _, opt := range boxstore.RetentionOptions() {
|
||||||
|
if opt.Seconds == retention {
|
||||||
|
m.RetentionKey = opt.Key
|
||||||
|
m.RetentionLabel = opt.Label
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.RetentionKey = "custom"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Changed("password") {
|
||||||
|
changes = append(changes, func(m *models.BoxManifest) error {
|
||||||
|
if password == "" {
|
||||||
|
m.PasswordHash = ""
|
||||||
|
m.PasswordHashAlg = ""
|
||||||
|
m.AuthToken = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
token, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not generate auth token")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not hash password: %w", err)
|
||||||
|
}
|
||||||
|
m.PasswordHash = string(hash)
|
||||||
|
m.PasswordHashAlg = "bcrypt"
|
||||||
|
m.AuthToken = token
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Changed("zip") {
|
||||||
|
changes = append(changes, func(m *models.BoxManifest) error {
|
||||||
|
if m.OneTimeDownload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.DisableZip = !zip
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Changed("one-time") {
|
||||||
|
changes = append(changes, func(m *models.BoxManifest) error {
|
||||||
|
if oneTime {
|
||||||
|
m.OneTimeDownload = true
|
||||||
|
m.DisableZip = false
|
||||||
|
if boxstore.OneTimeDownloadExpiry() > 0 {
|
||||||
|
m.RetentionSecs = boxstore.OneTimeDownloadExpiry()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.OneTimeDownload = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Changed("renew") {
|
||||||
|
changes = append(changes, func(m *models.BoxManifest) error {
|
||||||
|
secs := renewSeconds
|
||||||
|
if secs <= 0 {
|
||||||
|
secs = m.RetentionSecs
|
||||||
|
}
|
||||||
|
return renewBoxExpiry(m, secs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renewBoxExpiry(m *models.BoxManifest, seconds int64) error {
|
||||||
|
if seconds <= 0 || m.OneTimeDownload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m.ExpiresAt.IsZero() {
|
||||||
|
m.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.ExpiresAt = m.ExpiresAt.Add(time.Duration(seconds) * time.Second)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoxGetCommand() *cobra.Command {
|
||||||
|
var uploadRoot string
|
||||||
|
var asJSON bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "get",
|
||||||
|
Short: "Get box URL and info",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if uploadRoot != "" {
|
||||||
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
|
}
|
||||||
|
boxID := args[0]
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asJSON {
|
||||||
|
return formatBoxGetJSON(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Box ID:\t%s\n", boxID)
|
||||||
|
fmt.Printf("URL:\t/box/%s\n", boxID)
|
||||||
|
if !manifest.CreatedAt.IsZero() {
|
||||||
|
fmt.Printf("Created:\t%s\n", manifest.CreatedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
fmt.Printf("Expires:\t%s\n", manifest.ExpiresAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if boxstore.IsPasswordProtected(manifest) {
|
||||||
|
fmt.Println("Password:\tprotected")
|
||||||
|
}
|
||||||
|
if manifest.OneTimeDownload {
|
||||||
|
fmt.Println("Mode:\tone-time download")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
255
cmd/cmd_env.go
Normal file
255
cmd/cmd_env.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEnvCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "env",
|
||||||
|
Short: "Explore environment variable options",
|
||||||
|
Long: "List and inspect WarpBox environment variables sourced from the codebase.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newEnvListCommand())
|
||||||
|
cmd.AddCommand(newEnvDescribeCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvListCommand() *cobra.Command {
|
||||||
|
var format string
|
||||||
|
var includeHidden bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List all environment variables",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return formatEnvList(format, includeHidden)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json, env")
|
||||||
|
cmd.Flags().BoolVar(&includeHidden, "hidden", false, "Include non-editable and hard-limit settings")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvDescribeCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "describe",
|
||||||
|
Aliases: []string{"show", "info", "get"},
|
||||||
|
Short: "Describe an environment variable",
|
||||||
|
Long: "Show detailed info about a specific env var or setting key.",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return describeEnvVar(args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type envRow struct {
|
||||||
|
EnvName string
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Type config.SettingType
|
||||||
|
Default string
|
||||||
|
Editable bool
|
||||||
|
HardLimit bool
|
||||||
|
Minimum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type describeRow struct {
|
||||||
|
EnvName string
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Type config.SettingType
|
||||||
|
Default string
|
||||||
|
Value string
|
||||||
|
Source string
|
||||||
|
Editable bool
|
||||||
|
HardLimit bool
|
||||||
|
Minimum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatEnvList(format string, includeHidden bool) error {
|
||||||
|
allRows := buildAllEnvRows(includeHidden)
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "json":
|
||||||
|
type envOut struct {
|
||||||
|
EnvName string `json:"env_name"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
Editable bool `json:"editable"`
|
||||||
|
HardLimit bool `json:"hard_limit"`
|
||||||
|
Minimum int64 `json:"minimum,omitempty"`
|
||||||
|
}
|
||||||
|
out := make([]envOut, len(allRows))
|
||||||
|
for i, r := range allRows {
|
||||||
|
out[i] = envOut{
|
||||||
|
EnvName: r.EnvName, Key: r.Key, Label: r.Label,
|
||||||
|
Type: string(r.Type), Default: r.Default, Editable: r.Editable,
|
||||||
|
HardLimit: r.HardLimit, Minimum: r.Minimum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
|
||||||
|
case "env":
|
||||||
|
for _, r := range allRows {
|
||||||
|
fmt.Printf("%s=\"%s\"\n", r.EnvName, r.Default)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "table", "":
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ENV NAME\tKey\tLabel\tType\tDefault\tEditable")
|
||||||
|
for _, r := range allRows {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%v\n",
|
||||||
|
r.EnvName, r.Key, r.Label, r.Type, r.Default, r.Editable)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown format: %s (use 'table', 'json', or 'env')", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAllEnvRows(includeHidden bool) []envRow {
|
||||||
|
cfg, loadErr := config.Load()
|
||||||
|
|
||||||
|
var rows []envRow
|
||||||
|
|
||||||
|
for _, def := range config.Definitions {
|
||||||
|
if !includeHidden && (!def.Editable || def.HardLimit) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := envRow{
|
||||||
|
EnvName: def.EnvName,
|
||||||
|
Key: def.Key,
|
||||||
|
Label: def.Label,
|
||||||
|
Type: def.Type,
|
||||||
|
Editable: def.Editable,
|
||||||
|
HardLimit: def.HardLimit,
|
||||||
|
Minimum: def.Minimum,
|
||||||
|
}
|
||||||
|
if loadErr == nil {
|
||||||
|
row.Default = getEnvDefault(cfg, def)
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := buildExtraEnvRows(includeHidden)
|
||||||
|
rows = append(rows, extra...)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvDefault(cfg *config.Config, def config.SettingDefinition) string {
|
||||||
|
for _, row := range cfg.SettingRows() {
|
||||||
|
if row.Definition.Key == def.Key && row.Source == config.SourceDefault {
|
||||||
|
return row.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExtraEnvRows(includeHidden bool) []envRow {
|
||||||
|
extra := []envRow{
|
||||||
|
{EnvName: "WARPBOX_ADMIN_ENABLED", Key: "admin_enabled", Label: "Admin interface mode", Type: config.SettingTypeText, Editable: false, Default: "auto"},
|
||||||
|
{EnvName: "WARPBOX_ADMIN_USERNAME", Key: "admin_username", Label: "Admin username", Type: config.SettingTypeText, Editable: false, Default: "admin"},
|
||||||
|
{EnvName: "WARPBOX_ADMIN_PASSWORD", Key: "admin_password", Label: "Admin password", Type: config.SettingTypeText, Editable: false, Default: "(none)"},
|
||||||
|
{EnvName: "WARPBOX_ADMIN_EMAIL", Key: "admin_email", Label: "Admin email", Type: config.SettingTypeText, Editable: false, Default: "(none)"},
|
||||||
|
{EnvName: "WARPBOX_ADMIN_COOKIE_SECURE", Key: "admin_cookie_secure", Label: "Admin cookie secure flag", Type: config.SettingTypeBool, Editable: false, Default: "false"},
|
||||||
|
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeEnvVar(query string) error {
|
||||||
|
cfg, loadErr := config.Load()
|
||||||
|
|
||||||
|
for _, def := range config.Definitions {
|
||||||
|
if matchEnv(query, def.EnvName, def.Key) {
|
||||||
|
row := describeRow{
|
||||||
|
EnvName: def.EnvName,
|
||||||
|
Key: def.Key,
|
||||||
|
Label: def.Label,
|
||||||
|
Type: def.Type,
|
||||||
|
Editable: def.Editable,
|
||||||
|
HardLimit: def.HardLimit,
|
||||||
|
Minimum: def.Minimum,
|
||||||
|
}
|
||||||
|
if loadErr == nil {
|
||||||
|
for _, r := range cfg.SettingRows() {
|
||||||
|
if r.Definition.Key == def.Key {
|
||||||
|
row.Value = r.Value
|
||||||
|
row.Source = string(r.Source)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printDescribeRow(row)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extras := buildExtraEnvRows(true)
|
||||||
|
for _, er := range extras {
|
||||||
|
if matchEnv(query, er.EnvName, er.Key) {
|
||||||
|
row := describeRow{
|
||||||
|
EnvName: er.EnvName,
|
||||||
|
Key: er.Key,
|
||||||
|
Label: er.Label,
|
||||||
|
Type: er.Type,
|
||||||
|
Editable: er.Editable,
|
||||||
|
HardLimit: er.HardLimit,
|
||||||
|
Minimum: er.Minimum,
|
||||||
|
Default: er.Default,
|
||||||
|
}
|
||||||
|
printDescribeRow(row)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no environment variable found matching: %s\n\nUse 'warpbox env ls' to list all available options.", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchEnv(query, envName, key string) bool {
|
||||||
|
return strings.EqualFold(query, envName) || strings.EqualFold(query, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printDescribeRow(r describeRow) {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "Environment Variable:\t%s\n", r.EnvName)
|
||||||
|
fmt.Fprintf(w, "Setting Key:\t%s\n", r.Key)
|
||||||
|
fmt.Fprintf(w, "Label:\t%s\n", r.Label)
|
||||||
|
fmt.Fprintf(w, "Type:\t%s\n", r.Type)
|
||||||
|
fmt.Fprintf(w, "Editable (runtime):\t%v\n", r.Editable)
|
||||||
|
fmt.Fprintf(w, "Hard Limit:\t%v\n", r.HardLimit)
|
||||||
|
if r.Minimum > 0 {
|
||||||
|
fmt.Fprintf(w, "Minimum:\t%d\n", r.Minimum)
|
||||||
|
}
|
||||||
|
if r.Default != "" {
|
||||||
|
fmt.Fprintf(w, "Default:\t%s\n", r.Default)
|
||||||
|
}
|
||||||
|
if r.Value != "" {
|
||||||
|
fmt.Fprintf(w, "Current Value:\t%s\n", r.Value)
|
||||||
|
}
|
||||||
|
if r.Source != "" {
|
||||||
|
fmt.Fprintf(w, "Source:\t%s\n", r.Source)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
181
cmd/cmd_format.go
Normal file
181
cmd/cmd_format.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── List output ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func formatBoxSummariesTable(summaries []models.BoxSummary) error {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tFiles\tSize\tCreated\tExpires\tPassword\tOne-Time\tExpired")
|
||||||
|
for _, s := range summaries {
|
||||||
|
expires := "-"
|
||||||
|
if !s.ExpiresAt.IsZero() {
|
||||||
|
expires = s.ExpiresAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
created := s.CreatedAt.Format("2006-01-02 15:04:05")
|
||||||
|
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%v\t%v\t%v\n",
|
||||||
|
s.ID, s.FileCount, s.TotalSizeLabel, created, expires,
|
||||||
|
s.PasswordProtected, s.OneTimeDownload, s.Expired)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBoxSummariesJSON(summaries []models.BoxSummary) error {
|
||||||
|
type summaryOut struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
TotalSizeLabel string `json:"total_size_label"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
}
|
||||||
|
out := make([]summaryOut, len(summaries))
|
||||||
|
for i, s := range summaries {
|
||||||
|
out[i] = summaryOut{
|
||||||
|
ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize,
|
||||||
|
TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt,
|
||||||
|
ExpiresAt: s.ExpiresAt, Expired: s.Expired,
|
||||||
|
OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View output ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func formatBoxSummaryJSON(s *models.BoxSummary) error {
|
||||||
|
type summaryOut struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
TotalSizeLabel string `json:"total_size_label"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
}
|
||||||
|
out := summaryOut{
|
||||||
|
ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize,
|
||||||
|
TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt,
|
||||||
|
ExpiresAt: s.ExpiresAt, Expired: s.Expired,
|
||||||
|
OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBoxSummary(s *models.BoxSummary) {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintf(w, "ID:\t%s\n", s.ID)
|
||||||
|
fmt.Fprintf(w, "Files:\t%d\n", s.FileCount)
|
||||||
|
fmt.Fprintf(w, "Total Size:\t%s\n", s.TotalSizeLabel)
|
||||||
|
if !s.CreatedAt.IsZero() {
|
||||||
|
fmt.Fprintf(w, "Created:\t%s\n", s.CreatedAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if !s.ExpiresAt.IsZero() {
|
||||||
|
fmt.Fprintf(w, "Expires:\t%s\n", s.ExpiresAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Expired:\t%v\n", s.Expired)
|
||||||
|
fmt.Fprintf(w, "Password Protected:\t%v\n", s.PasswordProtected)
|
||||||
|
fmt.Fprintf(w, "One-Time Download:\t%v\n", s.OneTimeDownload)
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get output ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func formatBoxGetJSON(boxID string, manifest models.BoxManifest) error {
|
||||||
|
type getOut struct {
|
||||||
|
BoxID string `json:"box_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
RetentionKey string `json:"retention_key,omitempty"`
|
||||||
|
RetentionLabel string `json:"retention_label,omitempty"`
|
||||||
|
}
|
||||||
|
out := getOut{
|
||||||
|
BoxID: boxID, URL: "/box/" + boxID,
|
||||||
|
Expired: boxstore.IsExpired(manifest),
|
||||||
|
}
|
||||||
|
if !manifest.CreatedAt.IsZero() {
|
||||||
|
out.CreatedAt = manifest.CreatedAt
|
||||||
|
}
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
out.ExpiresAt = manifest.ExpiresAt
|
||||||
|
}
|
||||||
|
out.PasswordProtected = boxstore.IsPasswordProtected(manifest)
|
||||||
|
out.OneTimeDownload = manifest.OneTimeDownload
|
||||||
|
out.RetentionKey = manifest.RetentionKey
|
||||||
|
out.RetentionLabel = manifest.RetentionLabel
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change output ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func formatChangeResultJSON(boxID string, manifest models.BoxManifest) error {
|
||||||
|
type changeOut struct {
|
||||||
|
BoxID string `json:"box_id"`
|
||||||
|
Updated bool `json:"updated"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
DisableZip bool `json:"disable_zip"`
|
||||||
|
RetentionKey string `json:"retention_key,omitempty"`
|
||||||
|
RetentionLabel string `json:"retention_label,omitempty"`
|
||||||
|
RetentionSeconds int64 `json:"retention_seconds,omitempty"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
}
|
||||||
|
out := changeOut{
|
||||||
|
BoxID: boxID, Updated: true,
|
||||||
|
Expired: boxstore.IsExpired(manifest),
|
||||||
|
PasswordProtected: boxstore.IsPasswordProtected(manifest),
|
||||||
|
OneTimeDownload: manifest.OneTimeDownload,
|
||||||
|
DisableZip: manifest.DisableZip,
|
||||||
|
RetentionKey: manifest.RetentionKey,
|
||||||
|
RetentionLabel: manifest.RetentionLabel,
|
||||||
|
RetentionSeconds: manifest.RetentionSecs,
|
||||||
|
FileCount: len(manifest.Files),
|
||||||
|
}
|
||||||
|
if !manifest.CreatedAt.IsZero() {
|
||||||
|
out.CreatedAt = manifest.CreatedAt
|
||||||
|
}
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
out.ExpiresAt = manifest.ExpiresAt
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Retention options ────────────────────────────────────────
|
||||||
|
|
||||||
|
func printRetentionOptions() {
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "Key\tLabel\tSeconds")
|
||||||
|
for _, opt := range boxstore.RetentionOptions() {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%d\n", opt.Key, opt.Label, opt.Seconds)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
21
cmd/cmd_run.go
Normal file
21
cmd/cmd_run.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"warpbox/lib/server"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRunCommand() *cobra.Command {
|
||||||
|
var addr string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Run the HTTP server",
|
||||||
|
Long: "Run the WarpBox HTTP server.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return server.Run(addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&addr, "addr", ":8080", "HTTP server address")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
17
cmd/main.go
17
cmd/main.go
@@ -5,8 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"warpbox/lib/server"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -23,17 +21,8 @@ func newRootCommand() *cobra.Command {
|
|||||||
Long: "WarpBox provides commands for running and managing the WarpBox service.",
|
Long: "WarpBox provides commands for running and managing the WarpBox service.",
|
||||||
}
|
}
|
||||||
|
|
||||||
var addr string
|
rootCmd.AddCommand(newRunCommand())
|
||||||
runCmd := &cobra.Command{
|
rootCmd.AddCommand(newBoxCommand())
|
||||||
Use: "run",
|
rootCmd.AddCommand(newEnvCommand())
|
||||||
Short: "Run the HTTP server",
|
|
||||||
Long: "Run the WarpBox HTTP server. The root endpoint responds with ok.",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return server.Run(addr)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
runCmd.Flags().StringVar(&addr, "addr", ":8080", "HTTP server address")
|
|
||||||
|
|
||||||
rootCmd.AddCommand(runCmd)
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|||||||
13
docker-compose.example.yml
Normal file
13
docker-compose.example.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
warpbox:
|
||||||
|
image: warpbox:latest
|
||||||
|
container_name: warpbox
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# For podman please use :Z
|
||||||
|
# - ./data:/app/data:Z
|
||||||
|
- ./data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
194
docs/tech.md
Normal file
194
docs/tech.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# WarpBox Tech Stack
|
||||||
|
|
||||||
|
This document is a light technical map of WarpBox. It avoids deep internals,
|
||||||
|
but should be enough to understand what the project is built from and where the
|
||||||
|
main pieces live.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
WarpBox is written in Go.
|
||||||
|
|
||||||
|
Main libraries:
|
||||||
|
|
||||||
|
- `github.com/gin-gonic/gin` for HTTP routing, middleware, JSON responses, and
|
||||||
|
HTML template rendering.
|
||||||
|
- `github.com/gin-contrib/gzip` for compressed static asset responses.
|
||||||
|
- `github.com/spf13/cobra` for the small command-line interface.
|
||||||
|
|
||||||
|
The app starts from `cmd/main.go`. The `warpbox run` command calls the server
|
||||||
|
package, loads templates from `templates/*.html`, registers routes, mounts
|
||||||
|
`/static`, starts the thumbnail worker, and serves HTTP.
|
||||||
|
|
||||||
|
The main request surfaces are:
|
||||||
|
|
||||||
|
- `GET /` for the upload box UI.
|
||||||
|
- `GET /box/:id` for shared box pages.
|
||||||
|
- `GET /box/:id/login` and `POST /box/:id/login` for password-protected boxes.
|
||||||
|
- `GET /box/:id/download` for ZIP downloads.
|
||||||
|
- `GET /box/:id/files/:filename` for individual file downloads.
|
||||||
|
- `GET /box/:id/thumbnails/:file_id` for image previews.
|
||||||
|
- `POST /box` for new upload box creation.
|
||||||
|
- `POST /box/:id/files/:file_id/upload` for manifest-based uploads.
|
||||||
|
- `POST /box/:id/files/:file_id/status` for upload status updates.
|
||||||
|
- `POST /box/:id/upload` and `POST /upload` for legacy upload compatibility.
|
||||||
|
- `/admin/*` for the admin UI and settings.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The frontend is server-rendered HTML with vanilla JavaScript.
|
||||||
|
|
||||||
|
- Templates live in `templates/`.
|
||||||
|
- Browser behavior lives in `static/js/app.js` and `static/js/box.js`.
|
||||||
|
- Styling lives in `static/css/`.
|
||||||
|
- Visual assets, fonts, icons, cursors, popups, and sprites live under
|
||||||
|
`static/`.
|
||||||
|
|
||||||
|
There is no frontend build step. The browser receives HTML from Gin templates
|
||||||
|
and static assets directly from the Go server.
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
WarpBox uses the local filesystem for box data and BadgerDB for app metadata.
|
||||||
|
|
||||||
|
Uploaded boxes are stored under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
Each box directory contains uploaded files plus a `.warpbox.json` manifest.
|
||||||
|
The manifest tracks file names, statuses, retention, password metadata,
|
||||||
|
download options, and thumbnail state. BadgerDB stores users, tags, sessions,
|
||||||
|
and runtime settings overrides.
|
||||||
|
|
||||||
|
## Upload Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Browser as Browser UI
|
||||||
|
participant Server as Gin server
|
||||||
|
participant Store as boxstore
|
||||||
|
participant Disk as Local disk
|
||||||
|
|
||||||
|
Browser->>Server: POST /box
|
||||||
|
Server->>Store: create box directory + manifest
|
||||||
|
Store->>Disk: write .warpbox.json
|
||||||
|
Server-->>Browser: box id + upload URLs
|
||||||
|
Browser->>Server: POST /box/:id/files/:file_id/upload
|
||||||
|
Server->>Store: save file and update manifest
|
||||||
|
Store->>Disk: write file + manifest
|
||||||
|
Browser->>Server: POST /box/:id/files/:file_id/status
|
||||||
|
Server->>Store: update file status
|
||||||
|
Store->>Disk: rewrite manifest
|
||||||
|
Browser->>Server: GET /box/:id/status
|
||||||
|
Server-->>Browser: current file states
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Flow
|
||||||
|
|
||||||
|
Shared boxes are served from `/box/:id`.
|
||||||
|
|
||||||
|
Users can download individual files when the box allows it. ZIP downloads are
|
||||||
|
created on demand from the files currently marked complete. One-time download
|
||||||
|
boxes force ZIP download and delete the box after a successful ZIP response.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Shared[Shared box page]
|
||||||
|
File[Individual file download]
|
||||||
|
Zip[ZIP download]
|
||||||
|
OneTime[One-time ZIP only]
|
||||||
|
Delete[Delete box after success]
|
||||||
|
|
||||||
|
Shared --> File
|
||||||
|
Shared --> Zip
|
||||||
|
OneTime --> Zip
|
||||||
|
Zip --> Delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Thumbnail Worker
|
||||||
|
|
||||||
|
The thumbnail worker is a background goroutine. On each pass it scans upload
|
||||||
|
boxes, finds complete image files without thumbnails, generates small JPEG
|
||||||
|
previews, and updates the manifest.
|
||||||
|
|
||||||
|
Tuning is done with:
|
||||||
|
|
||||||
|
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
||||||
|
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Runtime configuration is centralized in `lib/config`. Startup applies built-in
|
||||||
|
defaults, environment variables, then safe BadgerDB settings overrides.
|
||||||
|
|
||||||
|
Storage paths are derived from `WARPBOX_DATA_DIR`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<WARPBOX_DATA_DIR>/uploads
|
||||||
|
<WARPBOX_DATA_DIR>/db
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin account is bootstrapped from `WARPBOX_ADMIN_PASSWORD` when no admin
|
||||||
|
user exists. If the password is empty, admin login stays disabled unless an
|
||||||
|
admin user already exists in BadgerDB.
|
||||||
|
|
||||||
|
Primary environment variables:
|
||||||
|
|
||||||
|
- `WARPBOX_DATA_DIR`
|
||||||
|
- `WARPBOX_ADMIN_PASSWORD`
|
||||||
|
- `WARPBOX_ADMIN_USERNAME`
|
||||||
|
- `WARPBOX_ADMIN_EMAIL`
|
||||||
|
- `WARPBOX_ADMIN_ENABLED`
|
||||||
|
- `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE`
|
||||||
|
- `WARPBOX_ADMIN_COOKIE_SECURE`
|
||||||
|
- `WARPBOX_GUEST_UPLOADS_ENABLED`
|
||||||
|
- `WARPBOX_API_ENABLED`
|
||||||
|
- `WARPBOX_ZIP_DOWNLOADS_ENABLED`
|
||||||
|
- `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED`
|
||||||
|
- `WARPBOX_RENEW_ON_ACCESS_ENABLED`
|
||||||
|
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
|
||||||
|
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
|
||||||
|
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
|
||||||
|
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB`
|
||||||
|
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
|
||||||
|
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
|
||||||
|
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
|
||||||
|
- `WARPBOX_SESSION_TTL_SECONDS`
|
||||||
|
- `WARPBOX_BOX_POLL_INTERVAL_MS`
|
||||||
|
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
||||||
|
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
||||||
|
|
||||||
|
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
|
||||||
|
accepts `auto`, `true`, or `false`.
|
||||||
|
|
||||||
|
The HTTP listen address is configured through the CLI flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd run --addr :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
cmd/main.go CLI setup
|
||||||
|
lib/server/server.go Gin engine setup and worker startup
|
||||||
|
lib/server/handlers.go HTTP handlers
|
||||||
|
lib/server/admin.go Admin handlers
|
||||||
|
lib/routing/routes.go Route table
|
||||||
|
lib/boxstore/store.go Box manifests, uploads, downloads, retention
|
||||||
|
lib/boxstore/thumbnails.go
|
||||||
|
Thumbnail scanning and generation
|
||||||
|
lib/config/config.go Typed config and settings definitions
|
||||||
|
lib/metastore/ BadgerDB metadata store
|
||||||
|
lib/models/models.go Shared data structures
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Existing tests cover config, storage, server security, and metastore behavior.
|
||||||
|
Run them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
19
go.mod
19
go.mod
@@ -1,11 +1,13 @@
|
|||||||
module warpbox
|
module warpbox
|
||||||
|
|
||||||
go 1.22
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/gzip v1.0.1
|
github.com/gin-contrib/gzip v1.0.1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
github.com/spf13/pflag v1.0.6
|
||||||
|
golang.org/x/crypto v0.39.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -19,6 +21,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
@@ -28,17 +31,15 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
40
go.sum
40
go.sum
@@ -6,7 +6,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
|||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -29,8 +29,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
|||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
@@ -62,13 +62,13 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
|
|||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -89,20 +89,18 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
|||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
222
lib/boxstore/files.go
Normal file
222
lib/boxstore/files.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListFiles(boxID string) ([]models.BoxFile, error) {
|
||||||
|
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
||||||
|
return DecorateFiles(boxID, manifest.Files), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return listCompletedFilesFromDisk(boxID)
|
||||||
|
}
|
||||||
|
func SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return models.BoxFile{}, err
|
||||||
|
}
|
||||||
|
if IsExpired(manifest) {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Box expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileIndex := -1
|
||||||
|
for index, manifestFile := range manifest.Files {
|
||||||
|
if manifestFile.ID == fileID {
|
||||||
|
fileIndex = index
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileIndex < 0 {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("File not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := manifest.Files[fileIndex].Name
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
|
||||||
|
}
|
||||||
|
|
||||||
|
destination, ok := SafeBoxFilePath(boxID, filename)
|
||||||
|
if !ok {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
||||||
|
}
|
||||||
|
if err := saveMultipartFile(file, destination); err != nil {
|
||||||
|
manifest.Files[fileIndex].Status = models.FileStatusFailed
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
writeManifestUnlocked(boxID, manifest)
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Files[fileIndex].Size = file.Size
|
||||||
|
manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename)
|
||||||
|
manifest.Files[fileIndex].Status = models.FileStatusReady
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
||||||
|
return models.BoxFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorateFile(boxID, manifest.Files[fileIndex]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error) {
|
||||||
|
filename, ok := helpers.SafeFilename(file.Filename)
|
||||||
|
if !ok {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
boxPath := BoxPath(boxID)
|
||||||
|
if err := os.MkdirAll(boxPath, 0755); err != nil {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Could not prepare upload box")
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = helpers.UniqueFilename(boxPath, filename)
|
||||||
|
destination, ok := SafeBoxFilePath(boxID, filename)
|
||||||
|
if !ok {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Invalid filename")
|
||||||
|
}
|
||||||
|
if err := saveMultipartFile(file, destination); err != nil {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Could not save uploaded file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorateFile(boxID, models.BoxFile{
|
||||||
|
ID: filename,
|
||||||
|
Name: filename,
|
||||||
|
Size: file.Size,
|
||||||
|
MimeType: helpers.MimeTypeForFile(destination, filename),
|
||||||
|
Status: models.FileStatusReady,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
|
||||||
|
if file.MimeType == "" {
|
||||||
|
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
|
||||||
|
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.SizeLabel == "" {
|
||||||
|
file.SizeLabel = helpers.FormatBytes(file.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
file.IconPath = IconForMimeType(file.MimeType, file.Name)
|
||||||
|
if file.ThumbnailPath != nil {
|
||||||
|
file.ThumbnailURL = *file.ThumbnailPath
|
||||||
|
}
|
||||||
|
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
||||||
|
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
||||||
|
file.IsComplete = file.Status == models.FileStatusReady
|
||||||
|
|
||||||
|
switch file.Status {
|
||||||
|
case models.FileStatusReady:
|
||||||
|
file.StatusLabel = "Ready"
|
||||||
|
file.Title = "Download " + file.Name
|
||||||
|
case models.FileStatusFailed:
|
||||||
|
file.StatusLabel = "Failed"
|
||||||
|
file.Title = "Failed to upload"
|
||||||
|
case models.FileStatusWork:
|
||||||
|
file.StatusLabel = "Loading"
|
||||||
|
file.Title = "Loading"
|
||||||
|
default:
|
||||||
|
file.Status = models.FileStatusWait
|
||||||
|
file.StatusLabel = "Waiting"
|
||||||
|
file.Title = "Loading"
|
||||||
|
}
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
|
||||||
|
decorated := make([]models.BoxFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
decorated = append(decorated, DecorateFile(boxID, file))
|
||||||
|
}
|
||||||
|
return decorated
|
||||||
|
}
|
||||||
|
func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) {
|
||||||
|
entries, err := os.ReadDir(BoxPath(boxID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]models.BoxFile, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || entry.Name() == manifestFile || entry.Type()&os.ModeSymlink != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
files = append(files, DecorateFile(boxID, models.BoxFile{
|
||||||
|
ID: name,
|
||||||
|
Name: name,
|
||||||
|
Size: info.Size(),
|
||||||
|
MimeType: helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), name), name),
|
||||||
|
Status: models.FileStatusReady,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
func saveMultipartFile(file *multipart.FileHeader, destination string) error {
|
||||||
|
source, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target, tempPath, err := createTempSibling(destination)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
target.Close()
|
||||||
|
if !committed {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := io.Copy(target, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := target.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempPath, destination); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTempSibling(destination string) (*os.File, string, error) {
|
||||||
|
directory := filepath.Dir(destination)
|
||||||
|
if err := os.MkdirAll(directory, 0755); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return target, target.Name(), nil
|
||||||
|
}
|
||||||
33
lib/boxstore/icons.go
Normal file
33
lib/boxstore/icons.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IconForMimeType(mimeType string, filename string) string {
|
||||||
|
extension := strings.ToLower(filepath.Ext(filename))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case extension == ".exe":
|
||||||
|
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
|
||||||
|
case strings.HasPrefix(mimeType, "image/"):
|
||||||
|
return "/static/img/sprites/bitmap.png"
|
||||||
|
case strings.HasPrefix(mimeType, "video/"):
|
||||||
|
return "/static/img/icons/netshow_notransm-1.png"
|
||||||
|
case strings.HasPrefix(mimeType, "audio/"):
|
||||||
|
return "/static/img/icons/netshow_notransm-1.png"
|
||||||
|
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
|
||||||
|
return "/static/img/sprites/notepad_file-1.png"
|
||||||
|
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
|
||||||
|
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
|
||||||
|
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
|
||||||
|
return "/static/img/sprites/font.png"
|
||||||
|
case extension == ".pdf":
|
||||||
|
return "/static/img/sprites/journal.png"
|
||||||
|
case extension == ".html" || extension == ".css" || extension == ".js":
|
||||||
|
return "/static/img/sprites/frame_web-0.png"
|
||||||
|
default:
|
||||||
|
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
lib/boxstore/manifest.go
Normal file
257
lib/boxstore/manifest.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var manifestMu sync.Mutex
|
||||||
|
|
||||||
|
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
|
||||||
|
retention := normalizeRetentionOption(request.RetentionKey)
|
||||||
|
usedNames := make(map[string]int, len(request.Files))
|
||||||
|
files := make([]models.BoxFile, 0, len(request.Files))
|
||||||
|
|
||||||
|
for _, fileRequest := range request.Files {
|
||||||
|
filename, ok := helpers.SafeFilename(fileRequest.Name)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Invalid filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = helpers.UniqueNameInBatch(filename, usedNames)
|
||||||
|
fileID, err := helpers.RandomHexID(8)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Could not create file id")
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, models.BoxFile{
|
||||||
|
ID: fileID,
|
||||||
|
Name: filename,
|
||||||
|
Size: fileRequest.Size,
|
||||||
|
MimeType: mimeType,
|
||||||
|
Status: models.FileStatusWait,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
disableZip := false
|
||||||
|
if request.AllowZip != nil {
|
||||||
|
disableZip = !*request.AllowZip
|
||||||
|
}
|
||||||
|
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
|
||||||
|
if oneTimeDownload {
|
||||||
|
disableZip = false
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
Files: files,
|
||||||
|
CreatedAt: now,
|
||||||
|
RetentionKey: retention.Key,
|
||||||
|
RetentionLabel: retention.Label,
|
||||||
|
RetentionSecs: retention.Seconds,
|
||||||
|
DisableZip: disableZip,
|
||||||
|
OneTimeDownload: oneTimeDownload,
|
||||||
|
}
|
||||||
|
|
||||||
|
if password := strings.TrimSpace(request.Password); password != "" {
|
||||||
|
authToken, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Could not secure upload box")
|
||||||
|
}
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Could not secure upload box")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.PasswordHash = string(passwordHash)
|
||||||
|
manifest.PasswordHashAlg = "bcrypt"
|
||||||
|
manifest.AuthToken = authToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteManifest(boxID, manifest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decoratedFiles := make([]models.BoxFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file))
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoratedFiles, nil
|
||||||
|
}
|
||||||
|
func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) {
|
||||||
|
if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed {
|
||||||
|
return models.BoxFile{}, fmt.Errorf("Invalid file status")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return models.BoxFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
if file.ID != fileID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Files[index].Status = status
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
||||||
|
return models.BoxFile{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorateFile(boxID, manifest.Files[index]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.BoxFile{}, fmt.Errorf("File not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadManifest(boxID string) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
return readManifestUnlocked(boxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteManifest(boxID string, manifest models.BoxManifest) error {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
return writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExpireBox(boxID string) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if delta <= 0 {
|
||||||
|
return manifest, fmt.Errorf("Invalid bump duration")
|
||||||
|
}
|
||||||
|
if manifest.OneTimeDownload {
|
||||||
|
return manifest, fmt.Errorf("One-time boxes cannot be extended")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := manifest.ExpiresAt
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if base.IsZero() || base.Before(now) {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = base.Add(delta)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileManifest(boxID string) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
path, ok := SafeBoxFilePath(boxID, file.Name)
|
||||||
|
if !ok || ensureRegularFile(path) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Status == models.FileStatusReady && file.Size == info.Size() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manifest is the UI source of truth, but disk wins when an upload
|
||||||
|
// was saved and the final status write/response was interrupted.
|
||||||
|
manifest.Files[index].Size = info.Size()
|
||||||
|
manifest.Files[index].MimeType = helpers.MimeTypeForFile(path, file.Name)
|
||||||
|
manifest.Files[index].Status = models.FileStatusReady
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
if err := writeManifestUnlocked(boxID, manifest); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
func readManifestUnlocked(boxID string) (models.BoxManifest, error) {
|
||||||
|
var manifest models.BoxManifest
|
||||||
|
data, err := os.ReadFile(ManifestPath(boxID))
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest writes are serialized because the browser can upload several files
|
||||||
|
// concurrently into the same box. Without this lock, status updates can race.
|
||||||
|
func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error {
|
||||||
|
data, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(ManifestPath(boxID), data, 0644)
|
||||||
|
}
|
||||||
79
lib/boxstore/paths.go
Normal file
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[:])
|
||||||
|
}
|
||||||
260
lib/boxstore/store_test.go
Normal file
260
lib/boxstore/store_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartRetentionWaitsForEveryFileToFinish(t *testing.T) {
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
{ID: "two", Status: models.FileStatusWork},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatalf("expected retention to stay unset while a file is still uploading, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
{ID: "two", Status: models.FileStatusFailed},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := time.Now().UTC()
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatal("expected retention to start once every file is complete or failed")
|
||||||
|
}
|
||||||
|
if manifest.ExpiresAt.Before(before.Add(9 * time.Second)) {
|
||||||
|
t.Fatalf("expected retention to start from completion time, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRetentionUsesConfiguredOneTimeDownloadExpiry(t *testing.T) {
|
||||||
|
restoreExpiry := OneTimeDownloadExpiry()
|
||||||
|
defer SetOneTimeDownloadExpiry(restoreExpiry)
|
||||||
|
SetOneTimeDownloadExpiry(30)
|
||||||
|
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
OneTimeDownload: true,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
{ID: "two", Status: models.FileStatusReady},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := time.Now().UTC()
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatal("expected one-time download expiry to start from configured expiry")
|
||||||
|
}
|
||||||
|
if manifest.ExpiresAt.Before(before.Add(29 * time.Second)) {
|
||||||
|
t.Fatalf("expected configured one-time expiry, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
if manifest.ExpiresAt.After(before.Add(31 * time.Second)) {
|
||||||
|
t.Fatalf("expected configured one-time expiry near 30s, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartRetentionSkipsOneTimeDownloadWhenExpiryZero(t *testing.T) {
|
||||||
|
restoreExpiry := OneTimeDownloadExpiry()
|
||||||
|
defer SetOneTimeDownloadExpiry(restoreExpiry)
|
||||||
|
SetOneTimeDownloadExpiry(0)
|
||||||
|
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
RetentionSecs: 10,
|
||||||
|
OneTimeDownload: true,
|
||||||
|
Files: []models.BoxFile{
|
||||||
|
{ID: "one", Status: models.FileStatusReady},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
startRetentionIfTerminalUnlocked(&manifest)
|
||||||
|
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
t.Fatalf("expected zero one-time expiry to keep expiry unset, got %s", manifest.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeBoxFilePathRejectsTraversal(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if _, ok := SafeBoxFilePath(boxID, "../outside.txt"); ok {
|
||||||
|
t.Fatal("expected traversal to be rejected")
|
||||||
|
}
|
||||||
|
if _, ok := SafeBoxFilePath("../bad", "file.txt"); ok {
|
||||||
|
t.Fatal("expected invalid box id to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddFileToZipRejectsUnsafeManifestName(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
zipWriter := zip.NewWriter(&buffer)
|
||||||
|
if err := AddFileToZip(zipWriter, "0123456789abcdef0123456789abcdef", "../outside.txt"); err == nil {
|
||||||
|
t.Fatal("expected unsafe zip filename to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFilesSkipsSymlinks(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(BoxPath(boxID), "safe.txt"), []byte("safe"), 0644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(BoxPath(boxID), "safe.txt"), filepath.Join(BoxPath(boxID), "link.txt")); err != nil {
|
||||||
|
t.Skipf("symlink unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFiles returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 || files[0].Name != "safe.txt" {
|
||||||
|
t.Fatalf("expected only regular file, got %#v", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailTasksSkipOneTimeDownloadBoxes(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := WriteManifest(boxID, models.BoxManifest{
|
||||||
|
OneTimeDownload: true,
|
||||||
|
Files: []models.BoxFile{{
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
Name: "image.png",
|
||||||
|
MimeType: "image/png",
|
||||||
|
Status: models.FileStatusReady,
|
||||||
|
}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tasks := collectBoxThumbnailTasks(boxID, 10); len(tasks) != 0 {
|
||||||
|
t.Fatalf("expected no thumbnail tasks for one-time box, got %#v", tasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := CreateManifest(boxID, models.CreateBoxRequest{Password: "secret"}); err != nil {
|
||||||
|
t.Fatalf("CreateManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest, err := ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.PasswordHashAlg != "bcrypt" {
|
||||||
|
t.Fatalf("expected bcrypt password hash, got %q", manifest.PasswordHashAlg)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(manifest, "secret") {
|
||||||
|
t.Fatal("expected bcrypt password to verify")
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy := models.BoxManifest{
|
||||||
|
PasswordSalt: "salt",
|
||||||
|
PasswordHash: legacyPasswordHash("salt", "secret"),
|
||||||
|
AuthToken: "token",
|
||||||
|
}
|
||||||
|
if !VerifyPassword(legacy, "secret") {
|
||||||
|
t.Fatal("expected legacy password hash to verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpireBoxMarksManifestExpired(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := WriteManifest(boxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expired, err := ExpireBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExpireBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !expired.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
|
t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) {
|
||||||
|
restoreUploadRoot := UploadRoot()
|
||||||
|
defer SetUploadRoot(restoreUploadRoot)
|
||||||
|
SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "fedcba9876543210fedcba9876543210"
|
||||||
|
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
base := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
||||||
|
ExpiresAt: base,
|
||||||
|
}
|
||||||
|
if err := WriteManifest(boxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := BumpBoxExpiry(boxID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BumpBoxExpiry returned error: %v", err)
|
||||||
|
}
|
||||||
|
expected := base.Add(24 * time.Hour)
|
||||||
|
if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) {
|
||||||
|
t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
lib/boxstore/summary.go
Normal file
74
lib/boxstore/summary.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListBoxSummaries() ([]models.BoxSummary, error) {
|
||||||
|
entries, err := os.ReadDir(uploadRoot)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries := make([]models.BoxSummary, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := BoxSummary(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summaries = append(summaries, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(summaries, func(i int, j int) bool {
|
||||||
|
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BoxSummary(boxID string) (models.BoxSummary, error) {
|
||||||
|
files, err := ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return models.BoxSummary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest models.BoxManifest
|
||||||
|
hasManifest := false
|
||||||
|
if readManifest, err := ReadManifest(boxID); err == nil {
|
||||||
|
manifest = readManifest
|
||||||
|
hasManifest = true
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := models.BoxSummary{
|
||||||
|
ID: boxID,
|
||||||
|
FileCount: len(files),
|
||||||
|
TotalSize: totalSize,
|
||||||
|
TotalSizeLabel: helpers.FormatBytes(totalSize),
|
||||||
|
}
|
||||||
|
if hasManifest {
|
||||||
|
summary.CreatedAt = manifest.CreatedAt
|
||||||
|
summary.ExpiresAt = manifest.ExpiresAt
|
||||||
|
summary.Expired = IsExpired(manifest)
|
||||||
|
summary.OneTimeDownload = manifest.OneTimeDownload
|
||||||
|
summary.PasswordProtected = IsPasswordProtected(manifest)
|
||||||
|
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
|
||||||
|
summary.CreatedAt = info.ModTime().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
267
lib/boxstore/thumbnails.go
Normal file
267
lib/boxstore/thumbnails.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
thumbnailDir = ".thumbnails"
|
||||||
|
thumbnailMaxSize = 160
|
||||||
|
)
|
||||||
|
|
||||||
|
type thumbnailTask struct {
|
||||||
|
BoxID string
|
||||||
|
FileID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartThumbnailWorker(batchSize int, interval time.Duration) {
|
||||||
|
if batchSize < 1 {
|
||||||
|
batchSize = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
ProcessThumbnailBatch(batchSize)
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProcessThumbnailBatch(batchSize int) int {
|
||||||
|
tasks := collectThumbnailTasks(batchSize)
|
||||||
|
for _, task := range tasks {
|
||||||
|
if err := generateThumbnail(task); err != nil {
|
||||||
|
markThumbnailFailed(task.BoxID, task.FileID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
|
||||||
|
if !helpers.ValidLowerHexID(fileID, 16) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return helpers.SafeChildPath(filepath.Join(BoxPath(boxID), thumbnailDir), fileID+".jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
||||||
|
entries, err := os.ReadDir(uploadRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]thumbnailTask, 0, batchSize)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, collectBoxThumbnailTasks(entry.Name(), batchSize-len(tasks))...)
|
||||||
|
if len(tasks) >= batchSize {
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
|
||||||
|
if remaining <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil || IsExpired(manifest) || manifest.OneTimeDownload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]thumbnailTask, 0, remaining)
|
||||||
|
changed := false
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
if len(tasks) >= remaining {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Status != models.FileStatusReady || file.ThumbnailPath != nil || file.ThumbnailStatus != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canGenerateThumbnail(file) {
|
||||||
|
manifest.Files[index].ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, thumbnailTask{
|
||||||
|
BoxID: boxID,
|
||||||
|
FileID: file.ID,
|
||||||
|
Name: file.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
func canGenerateThumbnail(file models.BoxFile) bool {
|
||||||
|
if strings.HasPrefix(file.MimeType, "image/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := strings.ToLower(filepath.Ext(file.Name))
|
||||||
|
return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateThumbnail(task thumbnailTask) error {
|
||||||
|
sourcePath, ok := SafeBoxFilePath(task.BoxID, task.Name)
|
||||||
|
if !ok {
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
if err := ensureRegularFile(sourcePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
src, _, err := image.Decode(source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb := resizeImage(src, thumbnailMaxSize)
|
||||||
|
if err := os.MkdirAll(filepath.Join(BoxPath(task.BoxID), thumbnailDir), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path, ok := ThumbnailFilePath(task.BoxID, task.FileID)
|
||||||
|
if !ok {
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
target, tempPath, err := createTempSibling(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
target.Close()
|
||||||
|
if !committed {
|
||||||
|
os.Remove(tempPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := target.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempPath, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
|
||||||
|
return markThumbnailReady(task.BoxID, task.FileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizeImage(src image.Image, maxSize int) image.Image {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
targetWidth := width
|
||||||
|
targetHeight := height
|
||||||
|
if width > maxSize || height > maxSize {
|
||||||
|
if width >= height {
|
||||||
|
targetWidth = maxSize
|
||||||
|
targetHeight = height * maxSize / width
|
||||||
|
} else {
|
||||||
|
targetHeight = maxSize
|
||||||
|
targetWidth = width * maxSize / height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetWidth < 1 {
|
||||||
|
targetWidth = 1
|
||||||
|
}
|
||||||
|
if targetHeight < 1 {
|
||||||
|
targetHeight = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||||
|
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||||
|
for y := 0; y < targetHeight; y++ {
|
||||||
|
for x := 0; x < targetWidth; x++ {
|
||||||
|
srcX := bounds.Min.X + x*width/targetWidth
|
||||||
|
srcY := bounds.Min.Y + y*height/targetHeight
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func markThumbnailReady(boxID string, fileID string) error {
|
||||||
|
path := "/box/" + boxID + "/thumbnails/" + url.PathEscape(fileID)
|
||||||
|
return updateThumbnailState(boxID, fileID, &path, models.ThumbnailStatusReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markThumbnailFailed(boxID string, fileID string) {
|
||||||
|
updateThumbnailState(boxID, fileID, nil, models.ThumbnailStatusFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateThumbnailState(boxID string, fileID string, thumbnailPath *string, status string) error {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, file := range manifest.Files {
|
||||||
|
if file.ID != fileID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Files[index].ThumbnailPath = thumbnailPath
|
||||||
|
manifest.Files[index].ThumbnailStatus = status
|
||||||
|
return writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
50
lib/boxstore/zip.go
Normal file
50
lib/boxstore/zip.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package boxstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
||||||
|
path, ok := SafeBoxFilePath(boxID, filename)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid file")
|
||||||
|
}
|
||||||
|
if err := ensureRegularFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
zipName, ok := safeZipEntryName(filename)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("Invalid zip entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
destination, err := zipWriter.Create(zipName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(destination, source)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func safeZipEntryName(filename string) (string, bool) {
|
||||||
|
filename = strings.TrimSpace(filename)
|
||||||
|
if filename == "" || filepath.IsAbs(filename) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := filepath.ToSlash(filepath.Clean(filename))
|
||||||
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return cleaned, true
|
||||||
|
}
|
||||||
197
lib/config/config_test.go
Normal file
197
lib/config/config_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaults(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.UploadsDir != filepath.Join("data", "uploads") {
|
||||||
|
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||||
|
}
|
||||||
|
if cfg.DBDir != filepath.Join("data", "db") {
|
||||||
|
t.Fatalf("unexpected db dir: %s", cfg.DBDir)
|
||||||
|
}
|
||||||
|
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
||||||
|
t.Fatal("expected default guest/API/download toggles to be enabled")
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "admin" {
|
||||||
|
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
if cfg.AdminPassword != "" {
|
||||||
|
t.Fatal("expected default admin password to be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentOverrides(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
|
||||||
|
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "0.5")
|
||||||
|
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
||||||
|
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||||
|
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.UploadsDir != filepath.Join("/tmp/warpbox-test", "uploads") {
|
||||||
|
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||||
|
}
|
||||||
|
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
||||||
|
t.Fatal("expected boolean environment overrides to be applied")
|
||||||
|
}
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if cfg.BoxPollIntervalMS != 2000 {
|
||||||
|
t.Fatalf("unexpected poll interval: %d", cfg.BoxPollIntervalMS)
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "root" {
|
||||||
|
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
if !cfg.OneTimeDownloadRetryOnFailure {
|
||||||
|
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
||||||
|
}
|
||||||
|
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||||
|
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||||
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
|
||||||
|
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||||
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidEnvironmentValues(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_SESSION_TTL_SECONDS", "1")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected invalid session ttl to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "maybe")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected invalid boolean to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsOverridePrecedence(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "true")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(map[string]string{SettingAPIEnabled: "false"}); err != nil {
|
||||||
|
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.APIEnabled {
|
||||||
|
t.Fatal("expected DB override to beat environment value")
|
||||||
|
}
|
||||||
|
if cfg.Source(SettingAPIEnabled) != SourceDB {
|
||||||
|
t.Fatalf("expected DB source, got %s", cfg.Source(SettingAPIEnabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsOverrideValidation(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
|
||||||
|
t.Fatal("expected negative expiry override to fail")
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "0.5"); err != nil {
|
||||||
|
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
|
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
||||||
|
t.Fatal("expected data_dir override to remain locked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearConfigEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"WARPBOX_DATA_DIR",
|
||||||
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
|
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||||
|
"WARPBOX_API_ENABLED",
|
||||||
|
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
|
||||||
|
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/config/definitions.go
Normal file
99
lib/config/definitions.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
var Definitions = []SettingDefinition{
|
||||||
|
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
||||||
|
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
|
||||||
|
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||||
|
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||||
|
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
|
rows := make([]SettingRow, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
rows = append(rows, SettingRow{
|
||||||
|
Definition: def,
|
||||||
|
Value: cfg.values[def.Key],
|
||||||
|
Source: cfg.sourceFor(def.Key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Source(key string) Source {
|
||||||
|
return cfg.sourceFor(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
||||||
|
switch cfg.AdminEnabled {
|
||||||
|
case AdminEnabledFalse:
|
||||||
|
return false
|
||||||
|
case AdminEnabledTrue:
|
||||||
|
return hasAdminUser
|
||||||
|
default:
|
||||||
|
return hasAdminUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Definition(key string) (SettingDefinition, bool) {
|
||||||
|
key = NormalizeLegacySettingKey(key)
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return def, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SettingDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeLegacySettingKey(key string) string {
|
||||||
|
switch key {
|
||||||
|
case "global_max_file_size_bytes":
|
||||||
|
return SettingGlobalMaxFileSizeBytes
|
||||||
|
case "global_max_box_size_bytes":
|
||||||
|
return SettingGlobalMaxBoxSizeBytes
|
||||||
|
case "default_user_max_file_size_bytes":
|
||||||
|
return SettingDefaultUserMaxFileBytes
|
||||||
|
case "default_user_max_box_size_bytes":
|
||||||
|
return SettingDefaultUserMaxBoxBytes
|
||||||
|
default:
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeOverrideInput(key string, value string) (string, string, error) {
|
||||||
|
normalizedKey := NormalizeLegacySettingKey(key)
|
||||||
|
switch key {
|
||||||
|
case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes":
|
||||||
|
parsed, err := parseInt64(value, 0)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedKey, "", err
|
||||||
|
}
|
||||||
|
return normalizedKey, formatGigabytesFromBytes(parsed), nil
|
||||||
|
default:
|
||||||
|
return normalizedKey, value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditableDefinitions() []SettingDefinition {
|
||||||
|
defs := make([]SettingDefinition, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Editable && !def.HardLimit {
|
||||||
|
defs = append(defs, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
276
lib/config/load.go
Normal file
276
lib/config/load.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
DataDir: "./data",
|
||||||
|
AdminUsername: "admin",
|
||||||
|
AdminEnabled: AdminEnabledAuto,
|
||||||
|
AllowAdminSettingsOverride: true,
|
||||||
|
GuestUploadsEnabled: true,
|
||||||
|
APIEnabled: true,
|
||||||
|
ZipDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||||
|
OneTimeDownloadRetryOnFailure: false,
|
||||||
|
DefaultGuestExpirySeconds: 10,
|
||||||
|
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||||
|
SessionTTLSeconds: 24 * 60 * 60,
|
||||||
|
BoxPollIntervalMS: 5000,
|
||||||
|
ThumbnailBatchSize: 10,
|
||||||
|
ThumbnailIntervalSeconds: 30,
|
||||||
|
sources: make(map[string]Source),
|
||||||
|
values: make(map[string]string),
|
||||||
|
defaults: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config precedence: defaults -> env -> overrides.
|
||||||
|
// Overrides are applied after Load by the server once the metadata store opens.
|
||||||
|
cfg.captureDefaults()
|
||||||
|
|
||||||
|
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||||
|
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||||
|
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
|
||||||
|
}
|
||||||
|
cfg.AdminEnabled = mode
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envBools := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
target *bool
|
||||||
|
}{
|
||||||
|
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
|
||||||
|
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
|
||||||
|
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
|
||||||
|
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
|
||||||
|
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||||
|
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||||
|
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||||
|
}
|
||||||
|
for _, item := range envBools {
|
||||||
|
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInt64s := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int64
|
||||||
|
target *int64
|
||||||
|
}{
|
||||||
|
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
|
||||||
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
|
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||||
|
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInt64s {
|
||||||
|
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sizeEnvVars := []struct {
|
||||||
|
key string
|
||||||
|
gbName string
|
||||||
|
mbName string
|
||||||
|
bytesName string
|
||||||
|
target *int64
|
||||||
|
}{
|
||||||
|
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
|
||||||
|
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
|
||||||
|
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
|
||||||
|
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
||||||
|
}
|
||||||
|
for _, item := range sizeEnvVars {
|
||||||
|
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInts := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int
|
||||||
|
target *int
|
||||||
|
}{
|
||||||
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
|
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInts {
|
||||||
|
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DataDir = filepath.Clean(cfg.DataDir)
|
||||||
|
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
|
||||||
|
cfg.DataDir = "data"
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||||
|
}
|
||||||
|
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||||
|
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||||
|
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||||
|
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) EnsureDirectories() error {
|
||||||
|
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (cfg *Config) captureDefaults() {
|
||||||
|
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
|
||||||
|
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
|
||||||
|
cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
|
||||||
|
cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
|
||||||
|
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
|
||||||
|
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
|
||||||
|
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
|
||||||
|
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
|
||||||
|
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
|
||||||
|
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
|
||||||
|
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
|
||||||
|
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||||
|
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||||
|
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||||
|
cfg.setValue(key, value, SourceDefault)
|
||||||
|
if cfg.defaults != nil {
|
||||||
|
cfg.defaults[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*target = raw
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, raw, SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, formatBool(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt64(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
|
||||||
|
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
|
||||||
|
parsed, err := parseGigabytes(rawGB, float64(min))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", gbName, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
|
||||||
|
parsed, err := parseInt64(rawBytes, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", bytesName, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawMB := strings.TrimSpace(os.Getenv(mbName))
|
||||||
|
if rawMB == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsedMB, err := parseInt64(rawMB, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", mbName, err)
|
||||||
|
}
|
||||||
|
parsedBytes := parsedMB * 1000 * 1000
|
||||||
|
*target = parsedBytes
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
102
lib/config/models.go
Normal file
102
lib/config/models.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceDefault Source = "default"
|
||||||
|
SourceEnv Source = "environment"
|
||||||
|
SourceDB Source = "db override"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminEnabledMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdminEnabledAuto AdminEnabledMode = "auto"
|
||||||
|
AdminEnabledTrue AdminEnabledMode = "true"
|
||||||
|
AdminEnabledFalse AdminEnabledMode = "false"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
||||||
|
SettingAPIEnabled = "api_enabled"
|
||||||
|
SettingZipDownloadsEnabled = "zip_downloads_enabled"
|
||||||
|
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
|
||||||
|
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
|
||||||
|
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
|
||||||
|
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
|
||||||
|
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||||
|
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||||
|
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||||
|
SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb"
|
||||||
|
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
|
||||||
|
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
|
||||||
|
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
|
||||||
|
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||||
|
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||||
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
|
SettingDataDir = "data_dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingTypeBool SettingType = "bool"
|
||||||
|
SettingTypeInt64 SettingType = "int64"
|
||||||
|
SettingTypeInt SettingType = "int"
|
||||||
|
SettingTypeText SettingType = "text"
|
||||||
|
SettingTypeSizeGB SettingType = "size_gb"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingDefinition struct {
|
||||||
|
Key string
|
||||||
|
EnvName string
|
||||||
|
Label string
|
||||||
|
Type SettingType
|
||||||
|
Editable bool
|
||||||
|
HardLimit bool
|
||||||
|
Minimum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingRow struct {
|
||||||
|
Definition SettingDefinition
|
||||||
|
Value string
|
||||||
|
Source Source
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
UploadsDir string
|
||||||
|
DBDir string
|
||||||
|
|
||||||
|
AdminPassword string
|
||||||
|
AdminUsername string
|
||||||
|
AdminEmail string
|
||||||
|
AdminEnabled AdminEnabledMode
|
||||||
|
AdminCookieSecure bool
|
||||||
|
AllowAdminSettingsOverride bool
|
||||||
|
|
||||||
|
GuestUploadsEnabled bool
|
||||||
|
APIEnabled bool
|
||||||
|
ZipDownloadsEnabled bool
|
||||||
|
OneTimeDownloadsEnabled bool
|
||||||
|
OneTimeDownloadExpirySeconds int64
|
||||||
|
OneTimeDownloadRetryOnFailure bool
|
||||||
|
RenewOnAccessEnabled bool
|
||||||
|
RenewOnDownloadEnabled bool
|
||||||
|
|
||||||
|
DefaultGuestExpirySeconds int64
|
||||||
|
MaxGuestExpirySeconds int64
|
||||||
|
GlobalMaxFileSizeBytes int64
|
||||||
|
GlobalMaxBoxSizeBytes int64
|
||||||
|
DefaultUserMaxFileSizeBytes int64
|
||||||
|
DefaultUserMaxBoxSizeBytes int64
|
||||||
|
SessionTTLSeconds int64
|
||||||
|
BoxPollIntervalMS int
|
||||||
|
ThumbnailBatchSize int
|
||||||
|
ThumbnailIntervalSeconds int
|
||||||
|
|
||||||
|
sources map[string]Source
|
||||||
|
values map[string]string
|
||||||
|
defaults map[string]string
|
||||||
|
}
|
||||||
76
lib/config/override_store.go
Normal file
76
lib/config/override_store.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AdminSettingsOverrideFilename = "admin_settings_overrides.json"
|
||||||
|
|
||||||
|
type adminSettingsOverrideFile struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
SavedAt string `json:"saved_at"`
|
||||||
|
Overrides map[string]string `json:"overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload adminSettingsOverrideFile
|
||||||
|
if err := json.Unmarshal(data, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if payload.Overrides == nil {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
normalized := make(map[string]string, len(payload.Overrides))
|
||||||
|
for key, value := range payload.Overrides {
|
||||||
|
nextKey, nextValue, err := NormalizeOverrideInput(key, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[nextKey] = nextValue
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
|
||||||
|
if overrides == nil {
|
||||||
|
overrides = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(overrides))
|
||||||
|
for key := range overrides {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
normalized := make(map[string]string, len(overrides))
|
||||||
|
for _, key := range keys {
|
||||||
|
normalized[key] = overrides[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := adminSettingsOverrideFile{
|
||||||
|
Format: "warpbox.admin.settings.overrides.v1",
|
||||||
|
SavedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Overrides: normalized,
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(payload, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
136
lib/config/overrides.go
Normal file
136
lib/config/overrides.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||||
|
if !cfg.AllowAdminSettingsOverride {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, value := range overrides {
|
||||||
|
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||||
|
def, ok := Definition(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown setting %q", key)
|
||||||
|
}
|
||||||
|
if !def.Editable || def.HardLimit {
|
||||||
|
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch def.Type {
|
||||||
|
case SettingTypeBool:
|
||||||
|
parsed, err := parseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignBool(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt64:
|
||||||
|
parsed, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
|
case SettingTypeSizeGB:
|
||||||
|
parsed, err := parseGigabytes(value, float64(def.Minimum))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt:
|
||||||
|
parsed64, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingGuestUploadsEnabled:
|
||||||
|
cfg.GuestUploadsEnabled = value
|
||||||
|
case SettingAPIEnabled:
|
||||||
|
cfg.APIEnabled = value
|
||||||
|
case SettingZipDownloadsEnabled:
|
||||||
|
cfg.ZipDownloadsEnabled = value
|
||||||
|
case SettingOneTimeDownloadsEnabled:
|
||||||
|
cfg.OneTimeDownloadsEnabled = value
|
||||||
|
case SettingRenewOnAccessEnabled:
|
||||||
|
cfg.RenewOnAccessEnabled = value
|
||||||
|
case SettingRenewOnDownloadEnabled:
|
||||||
|
cfg.RenewOnDownloadEnabled = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, formatBool(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingDefaultGuestExpirySecs:
|
||||||
|
cfg.DefaultGuestExpirySeconds = value
|
||||||
|
case SettingMaxGuestExpirySecs:
|
||||||
|
cfg.MaxGuestExpirySeconds = value
|
||||||
|
case SettingOneTimeDownloadExpirySecs:
|
||||||
|
cfg.OneTimeDownloadExpirySeconds = value
|
||||||
|
case SettingGlobalMaxFileSizeBytes:
|
||||||
|
cfg.GlobalMaxFileSizeBytes = value
|
||||||
|
case SettingGlobalMaxBoxSizeBytes:
|
||||||
|
cfg.GlobalMaxBoxSizeBytes = value
|
||||||
|
case SettingDefaultUserMaxFileBytes:
|
||||||
|
cfg.DefaultUserMaxFileSizeBytes = value
|
||||||
|
case SettingDefaultUserMaxBoxBytes:
|
||||||
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
|
case SettingSessionTTLSeconds:
|
||||||
|
cfg.SessionTTLSeconds = value
|
||||||
|
}
|
||||||
|
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
|
||||||
|
cfg.setValue(key, formatGigabytesFromBytes(value), source)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt(key string, value int, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingBoxPollIntervalMS:
|
||||||
|
cfg.BoxPollIntervalMS = value
|
||||||
|
case SettingThumbnailBatchSize:
|
||||||
|
cfg.ThumbnailBatchSize = value
|
||||||
|
case SettingThumbnailIntervalSeconds:
|
||||||
|
cfg.ThumbnailIntervalSeconds = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.Itoa(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.values[key] = value
|
||||||
|
cfg.sources[key] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) sourceFor(key string) Source {
|
||||||
|
source, ok := cfg.sources[key]
|
||||||
|
if !ok {
|
||||||
|
return SourceDefault
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) DefaultValue(key string) string {
|
||||||
|
if cfg.defaults == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cfg.defaults[key]
|
||||||
|
}
|
||||||
88
lib/config/parse.go
Normal file
88
lib/config/parse.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseBool(value string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "t", "true", "y", "yes", "on":
|
||||||
|
return true, nil
|
||||||
|
case "0", "f", "false", "n", "no", "off":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("must be a boolean")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(value string, min int64) (int64, error) {
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("must be an integer")
|
||||||
|
}
|
||||||
|
if parsed < min {
|
||||||
|
return 0, fmt.Errorf("must be at least %d", min)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(value string, min int) (int, error) {
|
||||||
|
parsed64, err := parseInt64(value, int64(min))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if parsed64 > int64(^uint(0)>>1) {
|
||||||
|
return 0, fmt.Errorf("is too large")
|
||||||
|
}
|
||||||
|
return int(parsed64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerGigabyte = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
func parseGigabytes(value string, min float64) (int64, error) {
|
||||||
|
raw := strings.TrimSpace(value)
|
||||||
|
lower := strings.ToLower(raw)
|
||||||
|
if strings.HasSuffix(lower, "gb") {
|
||||||
|
raw = strings.TrimSpace(raw[:len(raw)-2])
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(raw, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("must be a number of GB")
|
||||||
|
}
|
||||||
|
if parsed < min {
|
||||||
|
return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min))
|
||||||
|
}
|
||||||
|
bytes := parsed * bytesPerGigabyte
|
||||||
|
if bytes > math.MaxInt64 {
|
||||||
|
return 0, fmt.Errorf("is too large")
|
||||||
|
}
|
||||||
|
return int64(math.Round(bytes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGigabytesFromBytes(bytes int64) string {
|
||||||
|
if bytes <= 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
value := float64(bytes) / bytesPerGigabyte
|
||||||
|
return trimTrailingZeros(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingZeros(value float64) string {
|
||||||
|
text := strconv.FormatFloat(value, 'f', 3, 64)
|
||||||
|
text = strings.TrimRight(text, "0")
|
||||||
|
text = strings.TrimRight(text, ".")
|
||||||
|
if text == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBool(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
20
lib/helpers/env.go
Normal file
20
lib/helpers/env.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnvInt(name string, fallback int, minimum int) int {
|
||||||
|
rawValue := os.Getenv(name)
|
||||||
|
if rawValue == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(rawValue)
|
||||||
|
if err != nil || value < minimum {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
20
lib/helpers/format.go
Normal file
20
lib/helpers/format.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func FormatBytes(bytes int64) string {
|
||||||
|
units := []string{"B", "KB", "MB", "GB"}
|
||||||
|
size := float64(bytes)
|
||||||
|
unitIndex := 0
|
||||||
|
|
||||||
|
for size >= 1024 && unitIndex < len(units)-1 {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if unitIndex == 0 {
|
||||||
|
return fmt.Sprintf("%d %s", bytes, units[unitIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
|
||||||
|
}
|
||||||
30
lib/helpers/ids.go
Normal file
30
lib/helpers/ids.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RandomHexID(byteCount int) (string, error) {
|
||||||
|
bytes := make([]byte, byteCount)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidLowerHexID(value string, length int) bool {
|
||||||
|
if len(value) != length {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, character := range value {
|
||||||
|
if !strings.ContainsRune("0123456789abcdef", character) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
30
lib/helpers/mime.go
Normal file
30
lib/helpers/mime.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MimeTypeForFile(path string, filename string) string {
|
||||||
|
if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" {
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
bytesRead, err := file.Read(buffer)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.DetectContentType(buffer[:bytesRead])
|
||||||
|
}
|
||||||
58
lib/helpers/paths.go
Normal file
58
lib/helpers/paths.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SafeFilename(name string) (string, bool) {
|
||||||
|
filename := filepath.Base(name)
|
||||||
|
filename = strings.TrimSpace(filename)
|
||||||
|
return filename, filename != "" && filename != "." && filename != string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SafeChildPath(parent string, filename string) (string, bool) {
|
||||||
|
parent = filepath.Clean(parent)
|
||||||
|
filename = strings.TrimSpace(filename)
|
||||||
|
if parent == "" || filename == "" || filepath.IsAbs(filename) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Clean(filepath.Join(parent, filename))
|
||||||
|
relative, err := filepath.Rel(parent, path)
|
||||||
|
if err != nil || relative == "." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || relative == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func UniqueFilename(directory string, filename string) string {
|
||||||
|
if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := filepath.Ext(filename)
|
||||||
|
base := strings.TrimSuffix(filename, extension)
|
||||||
|
for count := 2; ; count++ {
|
||||||
|
candidate := base + "-" + strconv.Itoa(count) + extension
|
||||||
|
if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UniqueNameInBatch(filename string, usedNames map[string]int) string {
|
||||||
|
count := usedNames[filename]
|
||||||
|
usedNames[filename] = count + 1
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
extension := filepath.Ext(filename)
|
||||||
|
base := strings.TrimSuffix(filename, extension)
|
||||||
|
return base + "-" + strconv.Itoa(count+1) + extension
|
||||||
|
}
|
||||||
20
lib/helpers/paths_test.go
Normal file
20
lib/helpers/paths_test.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSafeChildPathRejectsTraversalAndAbsolutePaths(t *testing.T) {
|
||||||
|
parent := filepath.Join(t.TempDir(), "parent")
|
||||||
|
|
||||||
|
if _, ok := SafeChildPath(parent, "../outside.txt"); ok {
|
||||||
|
t.Fatal("expected traversal to be rejected")
|
||||||
|
}
|
||||||
|
if _, ok := SafeChildPath(parent, filepath.Join(string(filepath.Separator), "tmp", "outside.txt")); ok {
|
||||||
|
t.Fatal("expected absolute path to be rejected")
|
||||||
|
}
|
||||||
|
if path, ok := SafeChildPath(parent, "inside.txt"); !ok || path != filepath.Join(parent, "inside.txt") {
|
||||||
|
t.Fatalf("expected safe child path, got path=%q ok=%v", path, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/models/models.go
Normal file
85
lib/models/models.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
FileStatusFailed = "failed"
|
||||||
|
FileStatusReady = "complete"
|
||||||
|
FileStatusWait = "pending"
|
||||||
|
FileStatusWork = "uploading"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThumbnailStatusFailed = "failed"
|
||||||
|
ThumbnailStatusProcessing = "processing"
|
||||||
|
ThumbnailStatusReady = "ready"
|
||||||
|
ThumbnailStatusUnsupported = "unsupported"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RetentionOption struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxFile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SizeLabel string `json:"size_label"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusLabel string `json:"status_label"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
|
ThumbnailPath *string `json:"thumbnail_path"`
|
||||||
|
ThumbnailStatus string `json:"thumbnail_status,omitempty"`
|
||||||
|
ThumbnailURL string `json:"-"`
|
||||||
|
DownloadPath string `json:"download_path"`
|
||||||
|
UploadPath string `json:"upload_path"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxManifest struct {
|
||||||
|
Files []BoxFile `json:"files"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
RetentionKey string `json:"retention_key"`
|
||||||
|
RetentionLabel string `json:"retention_label"`
|
||||||
|
RetentionSecs int64 `json:"retention_seconds"`
|
||||||
|
PasswordSalt string `json:"password_salt,omitempty"`
|
||||||
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
|
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||||
|
AuthToken string `json:"auth_token,omitempty"`
|
||||||
|
DisableZip bool `json:"disable_zip,omitempty"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||||
|
Consumed bool `json:"consumed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BoxSummary struct {
|
||||||
|
ID string
|
||||||
|
FileCount int
|
||||||
|
TotalSize int64
|
||||||
|
TotalSizeLabel string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Expired bool
|
||||||
|
OneTimeDownload bool
|
||||||
|
PasswordProtected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateBoxRequest struct {
|
||||||
|
Files []CreateBoxFileRequest `json:"files"`
|
||||||
|
RetentionKey string `json:"retention_key"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
AllowZip *bool `json:"allow_zip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateBoxFileRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFileStatusRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
73
lib/routing/routes.go
Normal file
73
lib/routing/routes.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package routing
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
Health gin.HandlerFunc
|
||||||
|
Index gin.HandlerFunc
|
||||||
|
ShowBox gin.HandlerFunc
|
||||||
|
BoxLogin gin.HandlerFunc
|
||||||
|
BoxLoginPost gin.HandlerFunc
|
||||||
|
BoxStatus gin.HandlerFunc
|
||||||
|
DownloadBox gin.HandlerFunc
|
||||||
|
DownloadFile gin.HandlerFunc
|
||||||
|
DownloadThumbnail gin.HandlerFunc
|
||||||
|
CreateBox gin.HandlerFunc
|
||||||
|
ManifestFileUpload gin.HandlerFunc
|
||||||
|
FileStatusUpdate gin.HandlerFunc
|
||||||
|
DirectBoxUpload gin.HandlerFunc
|
||||||
|
LegacyUpload gin.HandlerFunc
|
||||||
|
|
||||||
|
AdminLogin gin.HandlerFunc
|
||||||
|
AdminLoginPost gin.HandlerFunc
|
||||||
|
AdminLogout gin.HandlerFunc
|
||||||
|
AdminDashboard gin.HandlerFunc
|
||||||
|
AdminAlerts gin.HandlerFunc
|
||||||
|
AdminBoxes gin.HandlerFunc
|
||||||
|
AdminBoxesAction gin.HandlerFunc
|
||||||
|
AdminUsers gin.HandlerFunc
|
||||||
|
AdminSettings gin.HandlerFunc
|
||||||
|
AdminSettingsExport gin.HandlerFunc
|
||||||
|
AdminSettingsSave gin.HandlerFunc
|
||||||
|
AdminSettingsImport gin.HandlerFunc
|
||||||
|
AdminSettingsReset gin.HandlerFunc
|
||||||
|
AdminAuth gin.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(router *gin.Engine, handlers Handlers) {
|
||||||
|
router.GET("/health", handlers.Health)
|
||||||
|
router.GET("/", handlers.Index)
|
||||||
|
|
||||||
|
router.GET("/box/:id", handlers.ShowBox)
|
||||||
|
router.GET("/box/:id/login", handlers.BoxLogin)
|
||||||
|
router.GET("/box/:id/status", handlers.BoxStatus)
|
||||||
|
router.GET("/box/:id/download", handlers.DownloadBox)
|
||||||
|
router.GET("/box/:id/files/:filename", handlers.DownloadFile)
|
||||||
|
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
|
||||||
|
|
||||||
|
router.POST("/box", handlers.CreateBox)
|
||||||
|
router.POST("/box/:id/login", handlers.BoxLoginPost)
|
||||||
|
router.POST("/box/:id/files/:file_id/upload", handlers.ManifestFileUpload)
|
||||||
|
router.POST("/box/:id/files/:file_id/status", handlers.FileStatusUpdate)
|
||||||
|
|
||||||
|
// Legacy upload routes are kept for compatibility with older clients.
|
||||||
|
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
||||||
|
router.POST("/upload", handlers.LegacyUpload)
|
||||||
|
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.GET("/login", handlers.AdminLogin)
|
||||||
|
admin.POST("/login", handlers.AdminLoginPost)
|
||||||
|
admin.GET("/logout", handlers.AdminLogout)
|
||||||
|
|
||||||
|
protected := router.Group("/admin", handlers.AdminAuth)
|
||||||
|
protected.GET("/dashboard", handlers.AdminDashboard)
|
||||||
|
protected.GET("/alerts", handlers.AdminAlerts)
|
||||||
|
protected.GET("/boxes", handlers.AdminBoxes)
|
||||||
|
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||||
|
protected.GET("/users", handlers.AdminUsers)
|
||||||
|
protected.GET("/settings", handlers.AdminSettings)
|
||||||
|
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||||
|
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||||
|
protected.POST("/settings/import", handlers.AdminSettingsImport)
|
||||||
|
protected.POST("/settings/reset", handlers.AdminSettingsReset)
|
||||||
|
}
|
||||||
116
lib/server/admin.go
Normal file
116
lib/server/admin.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminSessionCookie = "warpbox_admin_session"
|
||||||
|
const adminSessionMarker = "1"
|
||||||
|
|
||||||
|
func (app *App) adminLoginEnabled() bool {
|
||||||
|
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) adminAuthMiddleware(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := ctx.Cookie(adminSessionCookie)
|
||||||
|
if err != nil || token != app.adminSessionToken() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) adminSessionToken() string {
|
||||||
|
// A simple deterministic token derived from the admin credentials.
|
||||||
|
// This will improve when proper user/session storage is added.
|
||||||
|
return app.config.AdminUsername + ":" + app.config.AdminPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already logged in.
|
||||||
|
if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/login.html", gin.H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
|
||||||
|
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
||||||
|
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||||
|
"ErrorMessage": "Invalid username or password.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secure := app.config.AdminCookieSecure
|
||||||
|
maxAge := int(app.config.SessionTTLSeconds)
|
||||||
|
|
||||||
|
ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||||
|
secure := app.config.AdminCookieSecure
|
||||||
|
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardEnabled := config.AdminEnabledTrue
|
||||||
|
if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue {
|
||||||
|
dashboardEnabled = cfgVal
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "dashboard",
|
||||||
|
"DashboardEnabled": string(dashboardEnabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminAlerts(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "alerts",
|
||||||
|
})
|
||||||
|
}
|
||||||
316
lib/server/admin_boxes.go
Normal file
316
lib/server/admin_boxes.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminBoxesActionRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
BoxIDs []string `json:"box_ids"`
|
||||||
|
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxFileView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SizeLabel string `json:"size_label"`
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusLabel string `json:"status_label"`
|
||||||
|
DownloadPath string `json:"download_path"`
|
||||||
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusLabel string `json:"status_label"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
CompleteFiles int `json:"complete_files"`
|
||||||
|
PendingFiles int `json:"pending_files"`
|
||||||
|
FailedFiles int `json:"failed_files"`
|
||||||
|
TotalSizeLabel string `json:"total_size_label"`
|
||||||
|
CreatedAtLabel string `json:"created_at_label"`
|
||||||
|
CreatedAtISO string `json:"created_at_iso"`
|
||||||
|
ExpiresAtLabel string `json:"expires_at_label"`
|
||||||
|
ExpiresAtISO string `json:"expires_at_iso"`
|
||||||
|
RetentionLabel string `json:"retention_label"`
|
||||||
|
PasswordProtected bool `json:"password_protected"`
|
||||||
|
OneTimeDownload bool `json:"one_time_download"`
|
||||||
|
ZipDisabled bool `json:"zip_disabled"`
|
||||||
|
ZipAvailable bool `json:"zip_available"`
|
||||||
|
Consumed bool `json:"consumed"`
|
||||||
|
HasManifest bool `json:"has_manifest"`
|
||||||
|
OpenURL string `json:"open_url"`
|
||||||
|
ZipURL string `json:"zip_url"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
Files []adminBoxFileView `json:"files"`
|
||||||
|
SearchText string `json:"search_text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes, err := app.listAdminBoxes()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load boxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "boxes",
|
||||||
|
"Boxes": boxes,
|
||||||
|
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
|
||||||
|
var request adminBoxesActionRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(request.BoxIDs) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch request.Action {
|
||||||
|
case "delete", "expire", "bump":
|
||||||
|
default:
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Action == "bump" && request.DeltaSeconds <= 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
|
||||||
|
for _, boxID := range request.BoxIDs {
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch request.Action {
|
||||||
|
case "delete":
|
||||||
|
err = boxstore.DeleteBox(boxID)
|
||||||
|
case "expire":
|
||||||
|
_, err = boxstore.ExpireBox(boxID)
|
||||||
|
case "bump":
|
||||||
|
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes, err := app.listAdminBoxes()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if processed == 0 && len(warnings) > 0 {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(status, gin.H{
|
||||||
|
"ok": len(warnings) == 0,
|
||||||
|
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
|
||||||
|
"warnings": warnings,
|
||||||
|
"boxes": boxes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
|
||||||
|
summaries, err := boxstore.ListBoxSummaries()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes := make([]adminBoxView, 0, len(summaries))
|
||||||
|
for _, summary := range summaries {
|
||||||
|
boxView, err := app.buildAdminBoxView(summary.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boxes = append(boxes, boxView)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(boxes, func(i, j int) bool {
|
||||||
|
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
|
||||||
|
})
|
||||||
|
return boxes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
|
||||||
|
summary, err := boxstore.BoxSummary(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return adminBoxView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return adminBoxView{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, manifestErr := boxstore.ReadManifest(boxID)
|
||||||
|
hasManifest := manifestErr == nil
|
||||||
|
|
||||||
|
boxView := adminBoxView{
|
||||||
|
ID: summary.ID,
|
||||||
|
FileCount: summary.FileCount,
|
||||||
|
TotalSizeLabel: summary.TotalSizeLabel,
|
||||||
|
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
|
||||||
|
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
|
||||||
|
ExpiresAtLabel: "Not set",
|
||||||
|
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
|
||||||
|
RetentionLabel: "Legacy / unmanaged",
|
||||||
|
PasswordProtected: summary.PasswordProtected,
|
||||||
|
OneTimeDownload: summary.OneTimeDownload,
|
||||||
|
HasManifest: hasManifest,
|
||||||
|
OpenURL: "/box/" + summary.ID,
|
||||||
|
Files: make([]adminBoxFileView, 0, len(files)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !summary.ExpiresAt.IsZero() {
|
||||||
|
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParts := []string{summary.ID, summary.TotalSizeLabel}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsComplete {
|
||||||
|
boxView.CompleteFiles++
|
||||||
|
}
|
||||||
|
if file.Status == "failed" {
|
||||||
|
boxView.FailedFiles++
|
||||||
|
}
|
||||||
|
if !file.IsComplete && file.Status != "failed" {
|
||||||
|
boxView.PendingFiles++
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.Files = append(boxView.Files, adminBoxFileView{
|
||||||
|
Name: file.Name,
|
||||||
|
SizeLabel: file.SizeLabel,
|
||||||
|
MimeType: file.MimeType,
|
||||||
|
Status: file.Status,
|
||||||
|
StatusLabel: file.StatusLabel,
|
||||||
|
DownloadPath: file.DownloadPath,
|
||||||
|
ThumbnailURL: file.ThumbnailURL,
|
||||||
|
IsComplete: file.IsComplete,
|
||||||
|
})
|
||||||
|
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasManifest {
|
||||||
|
boxView.RetentionLabel = manifest.RetentionLabel
|
||||||
|
boxView.ZipDisabled = manifest.DisableZip
|
||||||
|
boxView.Consumed = manifest.Consumed
|
||||||
|
} else {
|
||||||
|
boxView.ZipDisabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
|
||||||
|
if boxView.ZipAvailable {
|
||||||
|
boxView.ZipURL = "/box/" + summary.ID + "/download"
|
||||||
|
}
|
||||||
|
|
||||||
|
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
|
||||||
|
boxView.Flags = deriveAdminBoxFlags(boxView)
|
||||||
|
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
|
||||||
|
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
|
||||||
|
|
||||||
|
return boxView, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
|
||||||
|
switch {
|
||||||
|
case !hasManifest:
|
||||||
|
return "legacy", "Legacy"
|
||||||
|
case consumed:
|
||||||
|
return "consumed", "Consumed"
|
||||||
|
case expired:
|
||||||
|
return "expired", "Expired"
|
||||||
|
case pendingFiles > 0:
|
||||||
|
return "uploading", "Uploading"
|
||||||
|
case failedFiles > 0:
|
||||||
|
return "attention", "Needs review"
|
||||||
|
default:
|
||||||
|
return "ready", "Ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAdminBoxFlags(box adminBoxView) []string {
|
||||||
|
flags := make([]string, 0, 5)
|
||||||
|
if box.PasswordProtected {
|
||||||
|
flags = append(flags, "protected")
|
||||||
|
}
|
||||||
|
if box.OneTimeDownload {
|
||||||
|
flags = append(flags, "one-time")
|
||||||
|
}
|
||||||
|
if box.ZipDisabled {
|
||||||
|
flags = append(flags, "zip off")
|
||||||
|
}
|
||||||
|
if !box.HasManifest {
|
||||||
|
flags = append(flags, "legacy")
|
||||||
|
}
|
||||||
|
if box.Consumed {
|
||||||
|
flags = append(flags, "consumed")
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminTimeLabel(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "Not set"
|
||||||
|
}
|
||||||
|
return value.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
|
||||||
|
switch action {
|
||||||
|
case "delete":
|
||||||
|
return fmt.Sprintf("Deleted %d box(es)", processed)
|
||||||
|
case "expire":
|
||||||
|
return fmt.Sprintf("Expired %d box(es)", processed)
|
||||||
|
case "bump":
|
||||||
|
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
|
||||||
|
default:
|
||||||
|
return "Action complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminBoxesDeltaLabel(deltaSeconds int64) string {
|
||||||
|
switch deltaSeconds {
|
||||||
|
case 24 * 60 * 60:
|
||||||
|
return "24h"
|
||||||
|
case 7 * 24 * 60 * 60:
|
||||||
|
return "7d"
|
||||||
|
default:
|
||||||
|
return (time.Duration(deltaSeconds) * time.Second).String()
|
||||||
|
}
|
||||||
|
}
|
||||||
471
lib/server/admin_settings.go
Normal file
471
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminSettingsCategoryView struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
Count int
|
||||||
|
Rows []adminSettingRowView
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingRowView struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
EnvName string `json:"env_name"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
CategoryLabel string `json:"category_label"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
DefaultValue string `json:"default_value"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SourceBadge string `json:"source_badge"`
|
||||||
|
Editable bool `json:"editable"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
HardLimit bool `json:"hard_limit"`
|
||||||
|
Minimum int64 `json:"minimum"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsSaveRequest struct {
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsImportRequest struct {
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
EditableSettings map[string]string `json:"editable_settings"`
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
Changes map[string]string `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsResetRequest struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminSettingsExportResponse struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
EditableSettings map[string]string `json:"editable_settings"`
|
||||||
|
Rows []adminSettingRowView `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||||
|
rows, categories := app.buildAdminSettingsRows()
|
||||||
|
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "settings",
|
||||||
|
"Rows": rows,
|
||||||
|
"Categories": categories,
|
||||||
|
"RowsJSON": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
|
||||||
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
|
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
|
||||||
|
var request adminSettingsSaveRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if currentOverrides == nil {
|
||||||
|
currentOverrides = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range request.Values {
|
||||||
|
currentOverrides[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
|
||||||
|
var request adminSettingsImportRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
values := request.Values
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.Settings
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.EditableSettings
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
values = request.Changes
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editable := map[string]bool{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
editable[def.Key] = true
|
||||||
|
}
|
||||||
|
filtered := make(map[string]string, len(values))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
for key, value := range values {
|
||||||
|
if editable[key] {
|
||||||
|
filtered[key] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, found := config.Definition(key); found {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||||
|
}
|
||||||
|
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range currentOverrides {
|
||||||
|
if _, exists := filtered[key]; !exists {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warnings = append(warnings, applyWarnings...)
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
||||||
|
var request adminSettingsResetRequest
|
||||||
|
_ = ctx.ShouldBindJSON(&request)
|
||||||
|
|
||||||
|
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if overrideSet == nil {
|
||||||
|
overrideSet = map[string]string{}
|
||||||
|
}
|
||||||
|
targetKeys := map[string]bool{}
|
||||||
|
for _, key := range request.Keys {
|
||||||
|
targetKeys[config.NormalizeLegacySettingKey(key)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetKeys) == 0 {
|
||||||
|
overrideSet = map[string]string{}
|
||||||
|
} else {
|
||||||
|
for key := range targetKeys {
|
||||||
|
delete(overrideSet, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": true,
|
||||||
|
"message": "Selected overrides cleared; environment and defaults now apply",
|
||||||
|
"warnings": warnings,
|
||||||
|
"rows": rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
|
||||||
|
if !app.config.AllowAdminSettingsOverride {
|
||||||
|
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
|
||||||
|
}
|
||||||
|
if values == nil {
|
||||||
|
values = map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
overrideSet := make(map[string]string, len(values))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
editable := map[string]config.SettingDefinition{}
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
editable[def.Key] = def
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(values))
|
||||||
|
for key := range values {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
key = normalizedKey
|
||||||
|
value := normalizedValue
|
||||||
|
def, ok := editable[key]
|
||||||
|
if !ok {
|
||||||
|
if _, found := config.Definition(key); found {
|
||||||
|
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value == "" && def.Type != config.SettingTypeText {
|
||||||
|
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
|
||||||
|
}
|
||||||
|
overrideSet[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
app.config = nextCfg
|
||||||
|
applyBoxstoreRuntimeConfig(app.config)
|
||||||
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
|
return rows, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
|
||||||
|
settings := make(map[string]string, len(rows))
|
||||||
|
editable := make(map[string]string)
|
||||||
|
for _, row := range rows {
|
||||||
|
settings[row.Key] = row.Value
|
||||||
|
if row.Editable && !row.Locked {
|
||||||
|
editable[row.Key] = row.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return adminSettingsExportResponse{
|
||||||
|
Format: "warpbox.settings.export.v1",
|
||||||
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Settings: settings,
|
||||||
|
EditableSettings: editable,
|
||||||
|
Rows: rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
|
||||||
|
cfgRows := app.config.SettingRows()
|
||||||
|
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
|
||||||
|
for _, row := range cfgRows {
|
||||||
|
rows = append(rows, app.makeDefinitionSettingRow(row))
|
||||||
|
}
|
||||||
|
rows = append(rows,
|
||||||
|
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
|
||||||
|
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
|
||||||
|
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
|
||||||
|
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
|
||||||
|
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
|
||||||
|
)
|
||||||
|
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Category == rows[j].Category {
|
||||||
|
return rows[i].Label < rows[j].Label
|
||||||
|
}
|
||||||
|
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
|
||||||
|
})
|
||||||
|
|
||||||
|
categoryMeta := settingsCategoryMeta()
|
||||||
|
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
|
||||||
|
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
|
||||||
|
categories = append(categories, allCategory)
|
||||||
|
|
||||||
|
grouped := map[string][]adminSettingRowView{}
|
||||||
|
for _, row := range rows {
|
||||||
|
grouped[row.Category] = append(grouped[row.Category], row)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, meta := range categoryMeta {
|
||||||
|
categories = append(categories, adminSettingsCategoryView{
|
||||||
|
Key: meta.Key,
|
||||||
|
Label: meta.Label,
|
||||||
|
Icon: meta.Icon,
|
||||||
|
Count: len(grouped[meta.Key]),
|
||||||
|
Rows: grouped[meta.Key],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows, categories
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolString(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
|
||||||
|
def := row.Definition
|
||||||
|
locked := !def.Editable || def.HardLimit
|
||||||
|
source := string(row.Source)
|
||||||
|
sourceBadge := source
|
||||||
|
if locked {
|
||||||
|
sourceBadge = "hard env"
|
||||||
|
}
|
||||||
|
return adminSettingRowView{
|
||||||
|
Key: def.Key,
|
||||||
|
Label: def.Label,
|
||||||
|
EnvName: def.EnvName,
|
||||||
|
Category: settingsCategoryForKey(def.Key),
|
||||||
|
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
|
||||||
|
Type: string(def.Type),
|
||||||
|
Value: row.Value,
|
||||||
|
DefaultValue: app.config.DefaultValue(def.Key),
|
||||||
|
Source: source,
|
||||||
|
SourceBadge: sourceBadge,
|
||||||
|
Editable: def.Editable && !def.HardLimit,
|
||||||
|
Locked: locked,
|
||||||
|
HardLimit: def.HardLimit,
|
||||||
|
Minimum: def.Minimum,
|
||||||
|
Description: settingsDescription(def.Key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
|
||||||
|
return adminSettingRowView{
|
||||||
|
Key: key,
|
||||||
|
Label: label,
|
||||||
|
EnvName: envName,
|
||||||
|
Category: category,
|
||||||
|
CategoryLabel: settingsCategoryLabel(category),
|
||||||
|
Type: rowType,
|
||||||
|
Value: value,
|
||||||
|
DefaultValue: "",
|
||||||
|
Source: "environment",
|
||||||
|
SourceBadge: "hard env",
|
||||||
|
Editable: false,
|
||||||
|
Locked: true,
|
||||||
|
HardLimit: true,
|
||||||
|
Description: description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsCategoryInfo struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryMeta() []settingsCategoryInfo {
|
||||||
|
return []settingsCategoryInfo{
|
||||||
|
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||||
|
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||||
|
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||||
|
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||||
|
{Key: "api", Label: "API", Icon: "{ }"},
|
||||||
|
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||||
|
{Key: "workers", Label: "Workers", Icon: "⚙"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryLabel(key string) string {
|
||||||
|
for _, meta := range settingsCategoryMeta() {
|
||||||
|
if meta.Key == key {
|
||||||
|
return meta.Label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "General"
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryRank(key string) int {
|
||||||
|
for index, meta := range settingsCategoryMeta() {
|
||||||
|
if meta.Key == key {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(settingsCategoryMeta()) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsCategoryForKey(key string) string {
|
||||||
|
switch key {
|
||||||
|
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||||
|
return "uploads"
|
||||||
|
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||||
|
return "downloads"
|
||||||
|
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||||
|
return "retention"
|
||||||
|
case config.SettingSessionTTLSeconds:
|
||||||
|
return "accounts"
|
||||||
|
case config.SettingAPIEnabled:
|
||||||
|
return "api"
|
||||||
|
case config.SettingDataDir:
|
||||||
|
return "storage"
|
||||||
|
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||||
|
return "workers"
|
||||||
|
default:
|
||||||
|
return "accounts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsDescription(key string) string {
|
||||||
|
descriptions := map[string]string{
|
||||||
|
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
|
||||||
|
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
|
||||||
|
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
|
||||||
|
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
|
||||||
|
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
|
||||||
|
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
|
||||||
|
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
|
||||||
|
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
||||||
|
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
||||||
|
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
||||||
|
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
|
||||||
|
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
|
||||||
|
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.",
|
||||||
|
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.",
|
||||||
|
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
||||||
|
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
||||||
|
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||||
|
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||||
|
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
||||||
|
}
|
||||||
|
return descriptions[key]
|
||||||
|
}
|
||||||
271
lib/server/admin_settings_test.go
Normal file
271
lib/server/admin_settings_test.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminSettingsRequiresAuth(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if location := response.Header().Get("Location"); location != "/admin/login" {
|
||||||
|
t.Fatalf("expected login redirect, got %q", location)
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("expected app setup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsPageRenders(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "WarpBox Settings") {
|
||||||
|
t.Fatalf("expected settings page title, got %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
|
||||||
|
t.Fatalf("expected API env var in page body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Settings map[string]string `json:"settings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Format != "warpbox.settings.export.v1" {
|
||||||
|
t.Fatalf("unexpected export format: %q", payload.Format)
|
||||||
|
}
|
||||||
|
if payload.Settings[config.SettingAPIEnabled] != "false" {
|
||||||
|
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000","global_max_file_size_gb":"0.5"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !app.config.APIEnabled {
|
||||||
|
t.Fatal("expected APIEnabled override to be applied")
|
||||||
|
}
|
||||||
|
if app.config.BoxPollIntervalMS != 6000 {
|
||||||
|
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
||||||
|
}
|
||||||
|
if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||||
|
t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
if overrides[config.SettingAPIEnabled] != "true" {
|
||||||
|
t.Fatalf("expected persisted API override, got %#v", overrides)
|
||||||
|
}
|
||||||
|
if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists {
|
||||||
|
t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides)
|
||||||
|
}
|
||||||
|
if _, exists := overrides[config.SettingSessionTTLSeconds]; exists {
|
||||||
|
t.Fatalf("expected untouched setting to stay out of overrides, got %#v", overrides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !app.config.APIEnabled {
|
||||||
|
t.Fatal("expected editable import value to apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Warnings) != 2 {
|
||||||
|
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
|
||||||
|
app, router := setupAdminSettingsTest(t)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.AddCookie(authCookie(app))
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if app.config.APIEnabled {
|
||||||
|
t.Fatal("expected reset to respect environment and restore APIEnabled=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Getwd returned error: %v", err)
|
||||||
|
}
|
||||||
|
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
t.Fatalf("Chdir returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Chdir(cwd)
|
||||||
|
})
|
||||||
|
clearAdminSettingsEnv(t)
|
||||||
|
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
|
||||||
|
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
|
t.Fatalf("EnsureDirectories returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
config: cfg,
|
||||||
|
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadHTMLTemplates returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.SetHTMLTemplate(htmlTemplates)
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.GET("/login", app.handleAdminLogin)
|
||||||
|
protected := router.Group("/admin", app.adminAuthMiddleware)
|
||||||
|
protected.GET("/settings", app.handleAdminSettings)
|
||||||
|
protected.GET("/settings/export", app.handleAdminSettingsExport)
|
||||||
|
protected.POST("/settings/save", app.handleAdminSettingsSave)
|
||||||
|
protected.POST("/settings/import", app.handleAdminSettingsImport)
|
||||||
|
protected.POST("/settings/reset", app.handleAdminSettingsReset)
|
||||||
|
return app, router
|
||||||
|
}
|
||||||
|
|
||||||
|
func authCookie(app *App) *http.Cookie {
|
||||||
|
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAdminSettingsEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"WARPBOX_DATA_DIR",
|
||||||
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
|
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||||
|
"WARPBOX_API_ENABLED",
|
||||||
|
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
|
||||||
|
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
20
lib/server/admin_users.go
Normal file
20
lib/server/admin_users.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled() {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
|
||||||
|
"AdminUsername": app.config.AdminUsername,
|
||||||
|
"AdminEmail": app.config.AdminEmail,
|
||||||
|
"ActivePage": "users",
|
||||||
|
})
|
||||||
|
}
|
||||||
135
lib/server/box_auth.go
Normal file
135
lib/server/box_auth.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const boxAuthCookiePrefix = "warpbox_box_"
|
||||||
|
|
||||||
|
func handleBoxLogin(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if boxstore.IsExpired(manifest) {
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
ctx.String(http.StatusGone, "Box expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBoxLogin(ctx, boxID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleBoxLoginPost(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if boxstore.IsExpired(manifest) {
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
ctx.String(http.StatusGone, "Box expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
|
||||||
|
renderBoxLogin(ctx, boxID, "The password was not accepted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAge := 24 * 60 * 60
|
||||||
|
if !manifest.ExpiresAt.IsZero() {
|
||||||
|
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
|
||||||
|
if seconds > 0 {
|
||||||
|
maxAge = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||||
|
}
|
||||||
|
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return models.BoxManifest{}, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if boxstore.IsExpired(manifest) {
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
if wantsHTML {
|
||||||
|
ctx.String(http.StatusGone, "Box expired")
|
||||||
|
} else {
|
||||||
|
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
|
||||||
|
}
|
||||||
|
return manifest, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest.OneTimeDownload && manifest.Consumed {
|
||||||
|
if wantsHTML {
|
||||||
|
ctx.String(http.StatusGone, "Box already consumed")
|
||||||
|
} else {
|
||||||
|
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
|
||||||
|
}
|
||||||
|
return manifest, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
|
||||||
|
if wantsHTML {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
|
||||||
|
} else {
|
||||||
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
|
||||||
|
}
|
||||||
|
return manifest, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.config.RenewOnAccessEnabled {
|
||||||
|
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
|
||||||
|
manifest = renewed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
|
||||||
|
token, err := ctx.Cookie(boxAuthCookieName(boxID))
|
||||||
|
return err == nil && boxstore.VerifyAuthToken(manifest, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxAuthCookieName(boxID string) string {
|
||||||
|
return boxAuthCookiePrefix + boxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
||||||
|
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
||||||
|
"BoxID": boxID,
|
||||||
|
"BoxUser": "WarpBox\\" + boxID,
|
||||||
|
"ErrorMessage": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
281
lib/server/downloads.go
Normal file
281
lib/server/downloads.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var oneTimeDownloadLocks sync.Map
|
||||||
|
|
||||||
|
func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !app.config.ZipDownloadsEnabled {
|
||||||
|
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
|
app.handleOneTimeDownloadBox(ctx, boxID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasManifest && manifest.DisableZip {
|
||||||
|
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !app.writeBoxZip(ctx, boxID, files) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||||
|
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||||
|
lock := oneTimeDownloadLock(boxID)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
defer oneTimeDownloadLocks.Delete(boxID)
|
||||||
|
|
||||||
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
|
||||||
|
ctx.String(http.StatusGone, "Box already consumed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allFilesComplete(files) {
|
||||||
|
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.config.OneTimeDownloadRetryOnFailure {
|
||||||
|
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Consumed = true
|
||||||
|
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !app.writeBoxZip(ctx, boxID, files) {
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
||||||
|
writeBoxZipHeaders(ctx, boxID)
|
||||||
|
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
|
||||||
|
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempPath := tempZip.Name()
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
|
||||||
|
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
|
||||||
|
tempZip.Close()
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tempZip.Seek(0, 0); err != nil {
|
||||||
|
tempZip.Close()
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBoxZipHeaders(ctx, boxID)
|
||||||
|
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
|
||||||
|
tempZip.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tempZip.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.Consumed = true
|
||||||
|
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boxstore.DeleteBox(boxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
|
||||||
|
ctx.Header("Content-Type", "application/zip")
|
||||||
|
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
|
||||||
|
zipWriter := zip.NewWriter(destination)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsComplete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
||||||
|
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
|
||||||
|
return lock.(*sync.Mutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allFilesComplete(files []models.BoxFile) bool {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsComplete {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestFilesReady(files []models.BoxFile) bool {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Status != models.FileStatusReady {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
|
||||||
|
stripped := make([]models.BoxFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
file.ThumbnailPath = nil
|
||||||
|
file.ThumbnailURL = ""
|
||||||
|
if file.ThumbnailStatus == "" {
|
||||||
|
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||||
|
}
|
||||||
|
stripped = append(stripped, file)
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
||||||
|
if !boxstore.ValidBoxID(boxID) || !ok {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
|
if !authorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
|
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
|
||||||
|
if !ok {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "File not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.FileAttachment(path, filename)
|
||||||
|
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||||
|
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
fileID := ctx.Param("file_id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
|
if !authorized {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
|
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
|
||||||
|
if !ok {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Thumbnail not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header("Content-Type", "image/jpeg")
|
||||||
|
ctx.File(path)
|
||||||
|
}
|
||||||
219
lib/server/one_time_test.go
Normal file
219
lib/server/one_time_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const oneTimeTestBoxID = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func TestOneTimeDownloadNotReadyDoesNotConsume(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, false)
|
||||||
|
writeOneTimeManifest(t, models.FileStatusWork, false)
|
||||||
|
|
||||||
|
response := performOneTimeDownload(app)
|
||||||
|
if response.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("expected not-ready download to return 409, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Consumed {
|
||||||
|
t.Fatal("expected not-ready box to remain unconsumed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTimeDownloadReadyConsumesAndDeletes(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, false)
|
||||||
|
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||||
|
|
||||||
|
response := performOneTimeDownload(app)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected ready download to return 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if _, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len())); err != nil {
|
||||||
|
t.Fatalf("expected valid zip body: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected consumed box to be deleted, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTimeDownloadWriterFailureConsumesByDefault(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, false)
|
||||||
|
writeOneTimeManifest(t, models.FileStatusReady, false)
|
||||||
|
|
||||||
|
response := performOneTimeDownload(app)
|
||||||
|
if response.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(boxstore.BoxPath(oneTimeTestBoxID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected failed ZIP to delete box by default, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTimeDownloadWriterFailureCanRemainRetryable(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, true)
|
||||||
|
writeOneTimeManifest(t, models.FileStatusReady, false)
|
||||||
|
|
||||||
|
response := performOneTimeDownload(app)
|
||||||
|
if response.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected failed ZIP to return 500, got %d", response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Consumed {
|
||||||
|
t.Fatal("expected failed retryable ZIP to remain unconsumed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTimeDownloadSecondAccessAfterConsumeIsGone(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, false)
|
||||||
|
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest.Consumed = true
|
||||||
|
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := performOneTimeDownload(app)
|
||||||
|
if response.Code != http.StatusGone {
|
||||||
|
t.Fatalf("expected consumed download to return 410, got %d", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneTimeStatusStripsThumbnailPath(t *testing.T) {
|
||||||
|
app := setupOneTimeDownloadTest(t, false)
|
||||||
|
app.config.APIEnabled = true
|
||||||
|
writeOneTimeManifest(t, models.FileStatusReady, true)
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(oneTimeTestBoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
thumbnailPath := "/box/" + oneTimeTestBoxID + "/thumbnails/0123456789abcdef"
|
||||||
|
manifest.Files[0].ThumbnailPath = &thumbnailPath
|
||||||
|
manifest.Files[0].ThumbnailStatus = models.ThumbnailStatusReady
|
||||||
|
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := performOneTimeStatus(app)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status to return 200, got %d", response.Code)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Files []models.BoxFile `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.Files) != 1 {
|
||||||
|
t.Fatalf("expected one file, got %#v", payload.Files)
|
||||||
|
}
|
||||||
|
if payload.Files[0].ThumbnailPath != nil {
|
||||||
|
t.Fatalf("expected one-time status to strip thumbnail path, got %q", *payload.Files[0].ThumbnailPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeConfigAppliesDBOneTimeExpiryOverride(t *testing.T) {
|
||||||
|
restoreExpiry := boxstore.OneTimeDownloadExpiry()
|
||||||
|
defer boxstore.SetOneTimeDownloadExpiry(restoreExpiry)
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(map[string]string{config.SettingOneTimeDownloadExpirySecs: "42"}); err != nil {
|
||||||
|
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
applyBoxstoreRuntimeConfig(cfg)
|
||||||
|
|
||||||
|
if got := boxstore.OneTimeDownloadExpiry(); got != 42 {
|
||||||
|
t.Fatalf("expected runtime one-time expiry to be updated from config, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOneTimeDownloadTest(t *testing.T, retryOnFailure bool) *App {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
restoreUploadRoot := boxstore.UploadRoot()
|
||||||
|
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
|
||||||
|
boxstore.SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
return &App{config: &config.Config{
|
||||||
|
ZipDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadRetryOnFailure: retryOnFailure,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeOneTimeManifest(t *testing.T, status string, createFile bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(boxstore.BoxPath(oneTimeTestBoxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if createFile {
|
||||||
|
path, ok := boxstore.SafeBoxFilePath(oneTimeTestBoxID, "file.txt")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SafeBoxFilePath rejected test file")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
Files: []models.BoxFile{{
|
||||||
|
ID: "0123456789abcdef",
|
||||||
|
Name: "file.txt",
|
||||||
|
Size: 5,
|
||||||
|
MimeType: "text/plain",
|
||||||
|
Status: status,
|
||||||
|
}},
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
OneTimeDownload: true,
|
||||||
|
}
|
||||||
|
if err := boxstore.WriteManifest(oneTimeTestBoxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func performOneTimeDownload(app *App) *httptest.ResponseRecorder {
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/box/:id/download", app.handleDownloadBox)
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/download", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func performOneTimeStatus(app *App) *httptest.ResponseRecorder {
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/box/:id/status", app.handleBoxStatus)
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/box/"+oneTimeTestBoxID+"/status", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
return response
|
||||||
|
}
|
||||||
100
lib/server/pages.go
Normal file
100
lib/server/pages.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func formatBrowserTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleIndex(ctx *gin.Context) {
|
||||||
|
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
|
"RetentionOptions": app.retentionOptions(),
|
||||||
|
"DefaultRetention": app.defaultRetentionOption().Key,
|
||||||
|
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
||||||
|
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
||||||
|
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleShowBox(ctx *gin.Context) {
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusNotFound, "Box not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
|
files = stripOneTimeThumbnailState(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAll := "/box/" + boxID + "/download"
|
||||||
|
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
||||||
|
downloadAll = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
||||||
|
"BoxID": boxID,
|
||||||
|
"Files": files,
|
||||||
|
"FileCount": len(files),
|
||||||
|
"DownloadAll": downloadAll,
|
||||||
|
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
||||||
|
"PollMS": app.config.BoxPollIntervalMS,
|
||||||
|
"RetentionLabel": manifest.RetentionLabel,
|
||||||
|
"ExpiresAt": manifest.ExpiresAt,
|
||||||
|
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (app *App) handleBoxStatus(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
boxID := ctx.Param("id")
|
||||||
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []models.BoxFile
|
||||||
|
if hasManifest && manifestFilesReady(manifest.Files) {
|
||||||
|
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
files, err = boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
|
files = stripOneTimeThumbnailState(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
|
||||||
|
}
|
||||||
49
lib/server/retention.go
Normal file
49
lib/server/retention.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *App) retentionAllowed(key string) bool {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, option := range app.retentionOptions() {
|
||||||
|
if option.Key == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) retentionOptions() []models.RetentionOption {
|
||||||
|
allOptions := boxstore.RetentionOptions()
|
||||||
|
options := make([]models.RetentionOption, 0, len(allOptions))
|
||||||
|
for _, option := range allOptions {
|
||||||
|
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options = append(options, option)
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
return allOptions[:1]
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) defaultRetentionOption() models.RetentionOption {
|
||||||
|
options := app.retentionOptions()
|
||||||
|
for _, option := range options {
|
||||||
|
if option.Seconds == app.config.DefaultGuestExpirySeconds {
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options[0]
|
||||||
|
}
|
||||||
37
lib/server/security_test.go
Normal file
37
lib/server/security_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
|
||||||
|
restoreUploadRoot := boxstore.UploadRoot()
|
||||||
|
defer boxstore.SetUploadRoot(restoreUploadRoot)
|
||||||
|
boxstore.SetUploadRoot(t.TempDir())
|
||||||
|
|
||||||
|
boxID := "0123456789abcdef0123456789abcdef"
|
||||||
|
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
manifest := models.BoxManifest{
|
||||||
|
Files: []models.BoxFile{{ID: "0123456789abcdef", Name: "file.txt", Status: models.FileStatusWait}},
|
||||||
|
ExpiresAt: time.Now().UTC().Add(-time.Second),
|
||||||
|
}
|
||||||
|
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{config: &config.Config{}}
|
||||||
|
if err := app.validateManifestFileUpload(boxID, "0123456789abcdef", 1); err == nil {
|
||||||
|
t.Fatal("expected expired box upload to be rejected")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(boxstore.BoxPath(boxID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,726 +1,123 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/hex"
|
"html/template"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"time"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/routing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
type App struct {
|
||||||
uploadRoot = "data/uploads"
|
config *config.Config
|
||||||
boxManifestFile = ".warpbox.json"
|
settingsOverridesPath string
|
||||||
fileStatusFailed = "failed"
|
|
||||||
fileStatusReady = "complete"
|
|
||||||
fileStatusWait = "pending"
|
|
||||||
fileStatusWork = "uploading"
|
|
||||||
)
|
|
||||||
|
|
||||||
var boxManifestMu sync.Mutex
|
|
||||||
|
|
||||||
type boxFile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
SizeLabel string `json:"size_label"`
|
|
||||||
MimeType string `json:"mime_type"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
StatusLabel string `json:"status_label"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
IconPath string `json:"icon_path"`
|
|
||||||
DownloadPath string `json:"download_path"`
|
|
||||||
UploadPath string `json:"upload_path"`
|
|
||||||
IsComplete bool `json:"is_complete"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type boxManifest struct {
|
|
||||||
Files []boxFile `json:"files"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type createBoxRequest struct {
|
|
||||||
Files []createBoxFileRequest `json:"files"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type createBoxFileRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateFileStatusRequest struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
|
||||||
|
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBoxstoreRuntimeConfig(cfg)
|
||||||
|
|
||||||
|
app := &App{config: cfg, settingsOverridesPath: overridesPath}
|
||||||
|
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.LoadHTMLGlob("templates/*.html")
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
|
|
||||||
router.GET("/", func(ctx *gin.Context) {
|
|
||||||
ctx.HTML(http.StatusOK, "index.html", gin.H{})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.GET("/box/:id", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !validBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := listBoxFiles(boxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
router.SetHTMLTemplate(htmlTemplates)
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
routing.Register(router, routing.Handlers{
|
||||||
"BoxID": boxID,
|
Health: app.handleHealth,
|
||||||
"Files": files,
|
Index: app.handleIndex,
|
||||||
"FileCount": len(files),
|
ShowBox: app.handleShowBox,
|
||||||
"DownloadAll": "/box/" + boxID + "/download",
|
BoxLogin: handleBoxLogin,
|
||||||
})
|
BoxLoginPost: handleBoxLoginPost,
|
||||||
})
|
BoxStatus: app.handleBoxStatus,
|
||||||
|
DownloadBox: app.handleDownloadBox,
|
||||||
|
DownloadFile: app.handleDownloadFile,
|
||||||
|
DownloadThumbnail: app.handleDownloadThumbnail,
|
||||||
|
CreateBox: app.handleCreateBox,
|
||||||
|
ManifestFileUpload: app.handleManifestFileUpload,
|
||||||
|
FileStatusUpdate: app.handleFileStatusUpdate,
|
||||||
|
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||||
|
LegacyUpload: app.handleLegacyUpload,
|
||||||
|
|
||||||
router.GET("/box/:id/status", func(ctx *gin.Context) {
|
AdminLogin: app.handleAdminLogin,
|
||||||
boxID := ctx.Param("id")
|
AdminLoginPost: app.handleAdminLoginPost,
|
||||||
if !validBoxID(boxID) {
|
AdminLogout: app.handleAdminLogout,
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
AdminDashboard: app.handleAdminDashboard,
|
||||||
return
|
AdminAlerts: app.handleAdminAlerts,
|
||||||
}
|
AdminBoxes: app.handleAdminBoxes,
|
||||||
|
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||||
files, err := listBoxFiles(boxID)
|
AdminUsers: app.handleAdminUsers,
|
||||||
if err != nil {
|
AdminSettings: app.handleAdminSettings,
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||||
return
|
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||||
}
|
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||||
|
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
AdminAuth: app.adminAuthMiddleware,
|
||||||
"box_id": boxID,
|
|
||||||
"files": files,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.GET("/box/:id/download", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !validBoxID(boxID) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := listBoxFiles(boxID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "Box not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Header("Content-Type", "application/zip")
|
|
||||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
|
||||||
|
|
||||||
zipWriter := zip.NewWriter(ctx.Writer)
|
|
||||||
defer zipWriter.Close()
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsComplete {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := addFileToZip(zipWriter, boxID, file.Name); err != nil {
|
|
||||||
ctx.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.GET("/box/:id/files/:filename", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
filename, ok := safeFilename(ctx.Param("filename"))
|
|
||||||
if !validBoxID(boxID) || !ok {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(boxPath(boxID), filename)
|
|
||||||
if !strings.HasPrefix(path, boxPath(boxID)+string(filepath.Separator)) {
|
|
||||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
ctx.String(http.StatusNotFound, "File not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.FileAttachment(path, filename)
|
|
||||||
})
|
|
||||||
|
|
||||||
router.POST("/box", func(ctx *gin.Context) {
|
|
||||||
boxID, err := newBoxID()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request createBoxRequest
|
|
||||||
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := createBoxManifest(boxID, request.Files)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
|
||||||
"box_id": boxID,
|
|
||||||
"box_url": "/box/" + boxID,
|
|
||||||
"files": files,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.POST("/box/:id/files/:file_id/upload", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
fileID := ctx.Param("file_id")
|
|
||||||
if !validBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := ctx.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
markManifestFileStatus(boxID, fileID, fileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFile, err := saveManifestUploadedFile(ctx, boxID, fileID, file)
|
|
||||||
if err != nil {
|
|
||||||
markManifestFileStatus(boxID, fileID, fileStatusFailed)
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
|
||||||
"box_id": boxID,
|
|
||||||
"box_url": "/box/" + boxID,
|
|
||||||
"file": savedFile,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.POST("/box/:id/files/:file_id/status", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
fileID := ctx.Param("file_id")
|
|
||||||
if !validBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request updateFileStatusRequest
|
|
||||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := markManifestFileStatus(boxID, fileID, request.Status)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.POST("/box/:id/upload", func(ctx *gin.Context) {
|
|
||||||
boxID := ctx.Param("id")
|
|
||||||
if !validBoxID(boxID) {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := ctx.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFile, err := saveUploadedFile(ctx, boxID, file)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
|
||||||
"box_id": boxID,
|
|
||||||
"box_url": "/box/" + boxID,
|
|
||||||
"file": savedFile,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
router.POST("/upload", func(ctx *gin.Context) {
|
|
||||||
form, err := ctx.MultipartForm()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
files := form.File["files"]
|
|
||||||
if len(files) == 0 {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
boxID, err := newBoxID()
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFiles := make([]gin.H, 0, len(files))
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
savedFile, err := saveUploadedFile(ctx, boxID, file)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savedFiles = append(savedFiles, savedFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
|
||||||
"box_id": boxID,
|
|
||||||
"box_url": "/box/" + boxID,
|
|
||||||
"files": savedFiles,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
compressed.Static("/static", "./static")
|
compressed.Static("/static", "./static")
|
||||||
|
|
||||||
|
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
||||||
|
|
||||||
return router.Run(addr)
|
return router.Run(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBoxID() (string, error) {
|
func loadHTMLTemplates() (*template.Template, error) {
|
||||||
bytes := make([]byte, 16)
|
tmpl := template.New("").Funcs(template.FuncMap{
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
"toJSON": func(value any) template.JS {
|
||||||
return "", err
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS("null")
|
||||||
}
|
}
|
||||||
|
return template.JS(data)
|
||||||
return hex.EncodeToString(bytes), nil
|
},
|
||||||
}
|
})
|
||||||
|
for _, pattern := range []string{
|
||||||
func newFileID() (string, error) {
|
"templates/*.html",
|
||||||
bytes := make([]byte, 8)
|
"templates/admin/*.html",
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
"templates/admin/partials/*.html",
|
||||||
return "", err
|
} {
|
||||||
}
|
var err error
|
||||||
|
tmpl, err = tmpl.ParseGlob(pattern)
|
||||||
return hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validBoxID(boxID string) bool {
|
|
||||||
if len(boxID) != 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, character := range boxID {
|
|
||||||
if !strings.ContainsRune("0123456789abcdef", character) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func boxPath(boxID string) string {
|
|
||||||
return filepath.Join(uploadRoot, boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func manifestPath(boxID string) string {
|
|
||||||
return filepath.Join(boxPath(boxID), boxManifestFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listBoxFiles(boxID string) ([]boxFile, error) {
|
|
||||||
if manifest, err := readBoxManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
|
||||||
files := make([]boxFile, 0, len(manifest.Files))
|
|
||||||
for _, file := range manifest.Files {
|
|
||||||
files = append(files, decorateBoxFile(boxID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(boxPath(boxID))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
files := make([]boxFile, 0, len(entries))
|
return tmpl, nil
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || entry.Name() == boxManifestFile {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := entry.Info()
|
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
|
||||||
if err != nil {
|
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||||
return nil, err
|
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
name := entry.Name()
|
func (app *App) handleHealth(c *gin.Context) {
|
||||||
mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name)
|
c.JSON(200, gin.H{
|
||||||
files = append(files, decorateBoxFile(boxID, boxFile{
|
"status": "healthy",
|
||||||
ID: name,
|
|
||||||
Name: name,
|
|
||||||
Size: info.Size(),
|
|
||||||
MimeType: mimeType,
|
|
||||||
Status: fileStatusReady,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createBoxManifest(boxID string, requests []createBoxFileRequest) ([]boxFile, error) {
|
|
||||||
usedNames := make(map[string]int, len(requests))
|
|
||||||
files := make([]boxFile, 0, len(requests))
|
|
||||||
|
|
||||||
for _, request := range requests {
|
|
||||||
filename, ok := safeFilename(request.Name)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename = uniqueManifestFilename(filename, usedNames)
|
|
||||||
fileID, err := newFileID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not create file id")
|
|
||||||
}
|
|
||||||
|
|
||||||
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
|
|
||||||
if mimeType == "" {
|
|
||||||
mimeType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
files = append(files, boxFile{
|
|
||||||
ID: fileID,
|
|
||||||
Name: filename,
|
|
||||||
Size: request.Size,
|
|
||||||
MimeType: mimeType,
|
|
||||||
Status: fileStatusWait,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest := boxManifest{Files: files}
|
|
||||||
if err := writeBoxManifest(boxID, manifest); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
decoratedFiles := make([]boxFile, 0, len(files))
|
|
||||||
for _, file := range files {
|
|
||||||
decoratedFiles = append(decoratedFiles, decorateBoxFile(boxID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoratedFiles, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBoxManifest(boxID string) (boxManifest, error) {
|
|
||||||
boxManifestMu.Lock()
|
|
||||||
defer boxManifestMu.Unlock()
|
|
||||||
|
|
||||||
return readBoxManifestUnlocked(boxID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readBoxManifestUnlocked(boxID string) (boxManifest, error) {
|
|
||||||
var manifest boxManifest
|
|
||||||
data, err := os.ReadFile(manifestPath(boxID))
|
|
||||||
if err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
||||||
return manifest, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBoxManifest(boxID string, manifest boxManifest) error {
|
|
||||||
boxManifestMu.Lock()
|
|
||||||
defer boxManifestMu.Unlock()
|
|
||||||
|
|
||||||
return writeBoxManifestUnlocked(boxID, manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBoxManifestUnlocked(boxID string, manifest boxManifest) error {
|
|
||||||
data, err := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(manifestPath(boxID), data, 0644)
|
|
||||||
}
|
|
||||||
|
|
||||||
func markManifestFileStatus(boxID string, fileID string, status string) (boxFile, error) {
|
|
||||||
if status != fileStatusWait && status != fileStatusWork && status != fileStatusReady && status != fileStatusFailed {
|
|
||||||
return boxFile{}, fmt.Errorf("Invalid file status")
|
|
||||||
}
|
|
||||||
|
|
||||||
boxManifestMu.Lock()
|
|
||||||
defer boxManifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readBoxManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return boxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for index, file := range manifest.Files {
|
|
||||||
if file.ID != fileID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Files[index].Status = status
|
|
||||||
if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
|
|
||||||
return boxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return decorateBoxFile(boxID, manifest.Files[index]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return boxFile{}, fmt.Errorf("File not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func decorateBoxFile(boxID string, file boxFile) boxFile {
|
|
||||||
if file.MimeType == "" {
|
|
||||||
file.MimeType = mimeTypeForFile(filepath.Join(boxPath(boxID), file.Name), file.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.SizeLabel == "" {
|
|
||||||
file.SizeLabel = formatBytes(file.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
file.IconPath = iconForMimeType(file.MimeType, file.Name)
|
|
||||||
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
|
|
||||||
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
|
|
||||||
file.IsComplete = file.Status == fileStatusReady
|
|
||||||
|
|
||||||
switch file.Status {
|
|
||||||
case fileStatusReady:
|
|
||||||
file.StatusLabel = "Ready"
|
|
||||||
file.Title = "Download " + file.Name
|
|
||||||
case fileStatusFailed:
|
|
||||||
file.StatusLabel = "Failed"
|
|
||||||
file.Title = "Failed to upload"
|
|
||||||
case fileStatusWork:
|
|
||||||
file.StatusLabel = "Loading"
|
|
||||||
file.Title = "Loading"
|
|
||||||
default:
|
|
||||||
file.Status = fileStatusWait
|
|
||||||
file.StatusLabel = "Waiting"
|
|
||||||
file.Title = "Loading"
|
|
||||||
}
|
|
||||||
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
|
||||||
path := filepath.Join(boxPath(boxID), filename)
|
|
||||||
source, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
destination, err := zipWriter.Create(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(destination, source)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveManifestUploadedFile(ctx *gin.Context, boxID string, fileID string, file *multipart.FileHeader) (boxFile, error) {
|
|
||||||
boxManifestMu.Lock()
|
|
||||||
defer boxManifestMu.Unlock()
|
|
||||||
|
|
||||||
manifest, err := readBoxManifestUnlocked(boxID)
|
|
||||||
if err != nil {
|
|
||||||
return boxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIndex := -1
|
|
||||||
for index, manifestFile := range manifest.Files {
|
|
||||||
if manifestFile.ID == fileID {
|
|
||||||
fileIndex = index
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileIndex < 0 {
|
|
||||||
return boxFile{}, fmt.Errorf("File not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := manifest.Files[fileIndex].Name
|
|
||||||
if err := os.MkdirAll(boxPath(boxID), 0755); err != nil {
|
|
||||||
return boxFile{}, fmt.Errorf("Could not prepare upload box")
|
|
||||||
}
|
|
||||||
|
|
||||||
destination := filepath.Join(boxPath(boxID), filename)
|
|
||||||
if err := ctx.SaveUploadedFile(file, destination); err != nil {
|
|
||||||
manifest.Files[fileIndex].Status = fileStatusFailed
|
|
||||||
writeBoxManifestUnlocked(boxID, manifest)
|
|
||||||
return boxFile{}, fmt.Errorf("Could not save uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest.Files[fileIndex].Size = file.Size
|
|
||||||
manifest.Files[fileIndex].MimeType = mimeTypeForFile(destination, filename)
|
|
||||||
manifest.Files[fileIndex].Status = fileStatusReady
|
|
||||||
if err := writeBoxManifestUnlocked(boxID, manifest); err != nil {
|
|
||||||
return boxFile{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return decorateBoxFile(boxID, manifest.Files[fileIndex]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) {
|
|
||||||
filename, ok := safeFilename(file.Filename)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("Invalid filename")
|
|
||||||
}
|
|
||||||
|
|
||||||
boxPath := boxPath(boxID)
|
|
||||||
if err := os.MkdirAll(boxPath, 0755); err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not prepare upload box")
|
|
||||||
}
|
|
||||||
|
|
||||||
filename = uniqueFilename(boxPath, filename)
|
|
||||||
destination := filepath.Join(boxPath, filename)
|
|
||||||
if err := ctx.SaveUploadedFile(file, destination); err != nil {
|
|
||||||
return nil, fmt.Errorf("Could not save uploaded file")
|
|
||||||
}
|
|
||||||
|
|
||||||
return gin.H{
|
|
||||||
"name": filename,
|
|
||||||
"size": file.Size,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeFilename(name string) (string, bool) {
|
|
||||||
filename := filepath.Base(name)
|
|
||||||
filename = strings.TrimSpace(filename)
|
|
||||||
return filename, filename != "" && filename != "." && filename != string(filepath.Separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueFilename(directory string, filename string) string {
|
|
||||||
if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) {
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
|
|
||||||
extension := filepath.Ext(filename)
|
|
||||||
base := strings.TrimSuffix(filename, extension)
|
|
||||||
for count := 2; ; count++ {
|
|
||||||
candidate := fmt.Sprintf("%s-%d%s", base, count, extension)
|
|
||||||
if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueManifestFilename(filename string, usedNames map[string]int) string {
|
|
||||||
count := usedNames[filename]
|
|
||||||
usedNames[filename] = count + 1
|
|
||||||
|
|
||||||
if count == 0 {
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
|
|
||||||
extension := filepath.Ext(filename)
|
|
||||||
base := strings.TrimSuffix(filename, extension)
|
|
||||||
return fmt.Sprintf("%s-%d%s", base, count+1, extension)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mimeTypeForFile(path string, filename string) string {
|
|
||||||
if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" {
|
|
||||||
return mimeType
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
buffer := make([]byte, 512)
|
|
||||||
bytesRead, err := file.Read(buffer)
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.DetectContentType(buffer[:bytesRead])
|
|
||||||
}
|
|
||||||
|
|
||||||
func iconForMimeType(mimeType string, filename string) string {
|
|
||||||
extension := strings.ToLower(filepath.Ext(filename))
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case extension == ".exe":
|
|
||||||
return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png"
|
|
||||||
case strings.HasPrefix(mimeType, "image/"):
|
|
||||||
return "/static/img/sprites/bitmap.png"
|
|
||||||
case strings.HasPrefix(mimeType, "video/"):
|
|
||||||
return "/static/img/icons/netshow_notransm-1.png"
|
|
||||||
case strings.HasPrefix(mimeType, "audio/"):
|
|
||||||
return "/static/img/icons/netshow_notransm-1.png"
|
|
||||||
case strings.HasPrefix(mimeType, "text/") || extension == ".md":
|
|
||||||
return "/static/img/sprites/notepad_file-1.png"
|
|
||||||
case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz":
|
|
||||||
return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png"
|
|
||||||
case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2":
|
|
||||||
return "/static/img/sprites/font.png"
|
|
||||||
case extension == ".pdf":
|
|
||||||
return "/static/img/sprites/journal.png"
|
|
||||||
case extension == ".html" || extension == ".css" || extension == ".js":
|
|
||||||
return "/static/img/sprites/frame_web-0.png"
|
|
||||||
default:
|
|
||||||
return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBytes(bytes int64) string {
|
|
||||||
units := []string{"B", "KB", "MB", "GB"}
|
|
||||||
size := float64(bytes)
|
|
||||||
unitIndex := 0
|
|
||||||
|
|
||||||
for size >= 1024 && unitIndex < len(units)-1 {
|
|
||||||
size /= 1024
|
|
||||||
unitIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
if unitIndex == 0 {
|
|
||||||
return fmt.Sprintf("%d %s", bytes, units[unitIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
42
run.sh
Executable file
42
run.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load .env if exists
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Core service switches.
|
||||||
|
export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
|
||||||
|
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
|
||||||
|
export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}"
|
||||||
|
export WARPBOX_ONE_TIME_DOWNLOADS_ENABLED="${WARPBOX_ONE_TIME_DOWNLOADS_ENABLED:-true}"
|
||||||
|
export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS:-604800}" # 7 days
|
||||||
|
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
|
||||||
|
|
||||||
|
# Storage and expiry limits used by the upload UI and backend validators.
|
||||||
|
# Use decimal gigabytes here. Examples: 2, 4, 0.5
|
||||||
|
export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
|
||||||
|
export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
|
||||||
|
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
|
||||||
|
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours
|
||||||
|
|
||||||
|
# Download-page refresh and thumbnail worker tuning.
|
||||||
|
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
|
||||||
|
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
||||||
|
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
||||||
|
|
||||||
|
# Data location.
|
||||||
|
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
||||||
|
|
||||||
|
# Admin Area
|
||||||
|
export WARPBOX_ADMIN_ENABLED="${WARPBOX_ADMIN_ENABLED:-true}"
|
||||||
|
export WARPBOX_ADMIN_PASSWORD="${WARPBOX_ADMIN_PASSWORD:-123}"
|
||||||
|
|
||||||
|
# Option to run via Docker Compose
|
||||||
|
if [ "${1:-}" = "--docker" ]; then
|
||||||
|
docker-compose up --build
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
go run ./cmd run
|
||||||
BIN
static/WarpBoxLogo.png
Normal file
BIN
static/WarpBoxLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
738
static/css/admin.css
Normal file
738
static/css/admin.css
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
/* ===========================
|
||||||
|
Admin Shell / Frame
|
||||||
|
=========================== */
|
||||||
|
.admin-shell {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 16px 34px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-frame {
|
||||||
|
width: min(var(--admin-frame-width, 1320px), 100%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Admin Taskbar (top nav)
|
||||||
|
=========================== */
|
||||||
|
.admin-taskbar {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #000000;
|
||||||
|
background-color: var(--w98-gray);
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.36), rgba(0,0,0,.08)), repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 4px 4px 0 rgba(0,0,0,.45);
|
||||||
|
padding: 3px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar.is-scrolled {
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0,0,0,.55), 0 11px 0 rgba(0,0,0,.18);
|
||||||
|
filter: brightness(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar.is-scrolled::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -10px;
|
||||||
|
height: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,.46), rgba(0,0,0,0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Start Button
|
||||||
|
=========================== */
|
||||||
|
.admin-start-button {
|
||||||
|
min-width: 108px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: 18px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-start-button:active {
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-start-logo {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
border: 1px solid #ffffff;
|
||||||
|
box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Taskbar Nav Buttons
|
||||||
|
=========================== */
|
||||||
|
.admin-taskbar-nav {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-button {
|
||||||
|
height: 24px;
|
||||||
|
min-width: 76px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-button:active {
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-button.is-active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-button:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Taskbar Session Chips
|
||||||
|
=========================== */
|
||||||
|
.admin-taskbar-session {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-chip,
|
||||||
|
.admin-alert-chip {
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-alert-chip.is-ok { background: #e8ffe8; border-color: #008000 #ffffff #ffffff #008000; }
|
||||||
|
.admin-alert-chip.is-info { background: #d8e5f8; }
|
||||||
|
.admin-alert-chip.is-warning {
|
||||||
|
background: #ffffcc;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
|
||||||
|
}
|
||||||
|
.admin-alert-chip.is-danger {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #800000;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Dashboard Window
|
||||||
|
=========================== */
|
||||||
|
.admin-dashboard-window,
|
||||||
|
.admin-workspace-window {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
color: #000000;
|
||||||
|
background-color: var(--w98-gray);
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-window > .win98-titlebar,
|
||||||
|
.admin-workspace-window > .win98-titlebar {
|
||||||
|
margin: 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-window > .menu-bar,
|
||||||
|
.admin-workspace-window > .menu-bar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 24px;
|
||||||
|
margin: 0 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-window > .menu-bar .menu-button,
|
||||||
|
.admin-workspace-window > .menu-bar .menu-button {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-window > .dashboard-body,
|
||||||
|
.admin-workspace-window > .admin-workspace-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
background-color: var(--w98-gray);
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-statusbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 160px 210px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 3px 4px 4px;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-statusbar span {
|
||||||
|
min-height: 19px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Menu Bar (toolbar)
|
||||||
|
=========================== */
|
||||||
|
.admin-menu-bar {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-button {
|
||||||
|
height: 20px;
|
||||||
|
min-width: 54px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-button:hover,
|
||||||
|
.admin-menu-button:focus-visible {
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-popup {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
left: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: 3px 3px 0 rgba(0,0,0,.35);
|
||||||
|
display: none;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-item.is-open .admin-menu-popup {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-action:hover,
|
||||||
|
.admin-menu-action:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 3px 2px;
|
||||||
|
background: #808080;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-action .shortcut {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-action:hover .shortcut {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Hero Section
|
||||||
|
=========================== */
|
||||||
|
.admin-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 330px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero-copy h2 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: center;
|
||||||
|
padding: 7px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero-status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-ok { color: #008000; }
|
||||||
|
.admin-status-warn { color: #8a6200; }
|
||||||
|
.admin-status-danger { color: #800000; }
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Stats Grid
|
||||||
|
=========================== */
|
||||||
|
.admin-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 122px;
|
||||||
|
padding: 10px 11px 10px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left accent bar */
|
||||||
|
.admin-stat-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 7px;
|
||||||
|
border-left: 7px solid #000078;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Severity color states */
|
||||||
|
.admin-stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
|
||||||
|
.admin-stat-card.is-ok::before { border-left-color: #008000; }
|
||||||
|
.admin-stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
|
||||||
|
.admin-stat-card.is-info::before { border-left-color: #000078; }
|
||||||
|
.admin-stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
|
||||||
|
.admin-stat-card.is-warning::before { border-left-color: #ffcc00; }
|
||||||
|
.admin-stat-card.is-danger {
|
||||||
|
color: #000000;
|
||||||
|
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
|
||||||
|
}
|
||||||
|
.admin-stat-card.is-danger::before { border-left-color: #800000; }
|
||||||
|
|
||||||
|
.admin-stat-label {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-value {
|
||||||
|
margin: 0 0 7px;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0;
|
||||||
|
color: #222222;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-note-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Main Grid / Section Windows
|
||||||
|
=========================== */
|
||||||
|
.admin-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-span-2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-window {
|
||||||
|
min-height: 0;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-body {
|
||||||
|
margin: 0 6px 6px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Quick Actions
|
||||||
|
=========================== */
|
||||||
|
.admin-link-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-button {
|
||||||
|
min-width: 112px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link-button:hover {
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titlebar action links (Show all) */
|
||||||
|
.titlebar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-link-button {
|
||||||
|
height: 18px;
|
||||||
|
min-width: 64px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-link-button:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Compact Mode
|
||||||
|
=========================== */
|
||||||
|
body.is-compact .admin-dashboard-body {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-compact .admin-section-body {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Responsive: Medium (tablets)
|
||||||
|
=========================== */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.admin-taskbar {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-session {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-span-2 {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Responsive: Small (mobile)
|
||||||
|
=========================== */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.admin-shell {
|
||||||
|
padding: 0 0 18px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-frame {
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-start-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-nav {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-button {
|
||||||
|
min-width: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-taskbar-session {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-session-chip,
|
||||||
|
.admin-alert-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-window,
|
||||||
|
.admin-workspace-window {
|
||||||
|
min-height: 100dvh;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-body {
|
||||||
|
padding: 6px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
min-height: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-menu-popup {
|
||||||
|
position: fixed;
|
||||||
|
left: 6px;
|
||||||
|
right: 6px;
|
||||||
|
top: 74px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-actions {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-link-button {
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-statusbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
min-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-titlebar h1,
|
||||||
|
.win98-titlebar h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-window-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override global main layout on admin pages since admin uses its own shell */
|
||||||
|
body:has(.admin-shell) main {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
394
static/css/alerts.css
Normal file
394
static/css/alerts.css
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
.alerts-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-stat-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
|
||||||
|
.alerts-stat-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-stat-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: rgba(255,255,255,.65);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a0a0a0;
|
||||||
|
border-bottom: 1px solid #a0a0a0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-list-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-actions-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-sub {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-tool-button,
|
||||||
|
.alerts-row-button,
|
||||||
|
.alerts-footer-button {
|
||||||
|
min-width: 64px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-action-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-input,
|
||||||
|
.alerts-select,
|
||||||
|
.alerts-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-input,
|
||||||
|
.alerts-select {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-table-wrap {
|
||||||
|
height: 430px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.alerts-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
.alerts-table tbody tr.is-selected { background: #c5dcff; }
|
||||||
|
|
||||||
|
.alerts-table td {
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-col-check { width: 34px; }
|
||||||
|
.alerts-col-severity { width: 76px; }
|
||||||
|
.alerts-col-status { width: 82px; }
|
||||||
|
.alerts-col-code { width: 70px; }
|
||||||
|
.alerts-col-time { width: 110px; }
|
||||||
|
.alerts-col-actions { width: 88px; }
|
||||||
|
|
||||||
|
.alerts-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-pill.low { background: #deebff; }
|
||||||
|
.alerts-pill.medium { background: #fff2c8; }
|
||||||
|
.alerts-pill.high { background: #ffdcdc; }
|
||||||
|
.alerts-pill.open { background: #f2e1ff; }
|
||||||
|
.alerts-pill.acked { background: #e2f0e2; }
|
||||||
|
.alerts-pill.closed { background: #ececec; }
|
||||||
|
|
||||||
|
.alerts-info-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-info-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #c0c0c0;
|
||||||
|
border-bottom: 1px solid #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-info-item strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-info-item span {
|
||||||
|
min-width: 0;
|
||||||
|
color: #222222;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-json-box {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px;
|
||||||
|
color: #b7ffc8;
|
||||||
|
background: #050505;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
font-family: "MonoCraft", "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-mini-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a08000;
|
||||||
|
border-bottom: 1px solid #a08000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-action-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-footerbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-footer-left,
|
||||||
|
.alerts-footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-status-pill {
|
||||||
|
min-height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.alerts-summary-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-toolbar-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.alerts-summary-grid,
|
||||||
|
.alerts-toolbar-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-table-wrap {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-panel-header,
|
||||||
|
.alerts-footerbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts-info-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,81 +15,423 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'PixelOperatorMono';
|
font-family: 'MonoCraft';
|
||||||
src: url('/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf');
|
src: url('/static/fonts/Monocraft.ttf');
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PixeloidSans';
|
|
||||||
src: url('/static/fonts/pixeloid_sans/PixeloidSans.ttf');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'PixeloidSans';
|
|
||||||
src: url('/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf');
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'PixeloidSans', 'PixelOperator', sans-serif, Arial, Helvetica;
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
font-smooth: never;
|
font-smooth: never;
|
||||||
|
-webkit-font-smoothing: none;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
|
|
||||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Normal\ Select.cur'), auto;
|
--base-font-size: 13px;
|
||||||
|
--ui-scale: 1;
|
||||||
--base-font-size: 14px;
|
|
||||||
|
|
||||||
/* Colours */
|
|
||||||
--w98-blue: #000078;
|
--w98-blue: #000078;
|
||||||
--w98-blue-gradient: linear-gradient(to right, #000078, 80%, #0f80cd);
|
--w98-blue-gradient: linear-gradient(90deg, #000078 0%, #000078 28%, #0f80cd 50%, #000078 72%, #000078 100%);
|
||||||
--w98-gray: #c0c0c0;
|
--w98-gray: #c0c0c0;
|
||||||
--w98-gray2: #a6a6a6;
|
--w98-gray2: #a6a6a6;
|
||||||
--w98-gray-gradient: linear-gradient(to bottom, #fff, 95%, #c0c0c0);
|
--ok: #008000;
|
||||||
|
--danger: #800000;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
* {
|
||||||
button,
|
box-sizing: border-box;
|
||||||
label[for],
|
scrollbar-width: auto;
|
||||||
.win98-button:not(:disabled) {
|
scrollbar-color: #c0c0c0 #808080;
|
||||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Link\ Select.cur'), auto;
|
image-rendering: pixelated;
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="file"],
|
|
||||||
textarea,
|
|
||||||
[contenteditable="true"] {
|
|
||||||
cursor: url('/static/cursors/vaporwave-hotline-white-plus/Hotline\ Black\ Handwriting.cur'), text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
min-height: 100%;
|
||||||
font-size: var(--base-font-size);
|
font-size: var(--base-font-size);
|
||||||
color: white;
|
color: #ffffff;
|
||||||
background-color: #000;
|
background: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 100vw;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
height: auto;
|
overflow-x: hidden;
|
||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
background-image: url('/static/img/bg/stars1.gif');
|
background-image: url('/static/img/bg/stars1.gif');
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
|
background-size: auto;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 100vw;
|
padding: 18px;
|
||||||
min-height: 100vh;
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
label[for],
|
||||||
|
.menu-button,
|
||||||
|
.win98-button:not(:disabled),
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="file"],
|
||||||
|
textarea {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px dotted #000078;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
background: #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: repeating-linear-gradient(45deg, #808080 0 2px, #8f8f8f 2px 4px);
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb,
|
||||||
|
::-webkit-scrollbar-button:single-button {
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button:single-button {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 7px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button:single-button:vertical:decrement {
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, #000000 50%), linear-gradient(135deg, #000000 50%, transparent 50%);
|
||||||
|
background-position: 5px 6px, 8px 6px;
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button:single-button:vertical:increment {
|
||||||
|
background-image: linear-gradient(225deg, transparent 50%, #000000 50%), linear-gradient(315deg, #000000 50%, transparent 50%);
|
||||||
|
background-position: 5px 7px, 8px 7px;
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button:single-button:horizontal:decrement {
|
||||||
|
background-image: linear-gradient(135deg, transparent 50%, #000000 50%), linear-gradient(45deg, #000000 50%, transparent 50%);
|
||||||
|
background-position: 6px 5px, 6px 8px;
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button:single-button:horizontal:increment {
|
||||||
|
background-image: linear-gradient(315deg, transparent 50%, #000000 50%), linear-gradient(225deg, #000000 50%, transparent 50%);
|
||||||
|
background-position: 7px 5px, 7px 8px;
|
||||||
|
background-size: 4px 4px, 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover,
|
||||||
|
::-webkit-scrollbar-button:single-button:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active,
|
||||||
|
::-webkit-scrollbar-button:single-button:active {
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: #c0c0c0;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button {
|
||||||
|
min-width: 92px;
|
||||||
|
height: 28px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button:disabled,
|
||||||
|
.win98-button[aria-disabled="true"],
|
||||||
|
button:disabled,
|
||||||
|
button[aria-disabled="true"],
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button:disabled,
|
||||||
|
.win98-button[aria-disabled="true"] {
|
||||||
|
color: #808080;
|
||||||
|
text-shadow: 1px 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-button:active:not(:disabled):not([aria-disabled="true"]),
|
||||||
|
.win98-control:active,
|
||||||
|
.menu-button[aria-expanded="true"] {
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
background: rgba(128, 128, 128, .42);
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(780px, calc(100vw - 24px));
|
||||||
|
max-height: min(760px, calc(100vh - 24px));
|
||||||
|
display: none;
|
||||||
|
z-index: 80;
|
||||||
|
zoom: var(--ui-scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-visible {
|
||||||
|
display: flex;
|
||||||
|
animation: popup-open-v10 180ms steps(5, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 90px);
|
||||||
|
margin: 0 6px 6px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #000000;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body h3 { margin: 0 0 8px; font-size: 16px; line-height: 18px; }
|
||||||
|
.popup-body h4 { margin: 14px 0 6px; font-size: 14px; line-height: 16px; }
|
||||||
|
.popup-body p { margin: 0 0 8px; }
|
||||||
|
.popup-body ul,
|
||||||
|
.popup-body ol { margin: 0 0 8px 18px; padding: 0; }
|
||||||
|
.popup-body li { margin: 0 0 4px; }
|
||||||
|
.popup-body .code-block {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 8px;
|
||||||
|
color: #00ff66;
|
||||||
|
background: #000000;
|
||||||
|
border: 0;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: pre;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
box-sizing: border-box;
|
||||||
|
contain: layout paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body .code-block code {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
white-space: inherit;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-fallback-text {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-properties-popup {
|
||||||
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-preview-popup {
|
||||||
|
width: min(760px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 52px;
|
||||||
|
max-width: min(360px, calc(100vw - 24px));
|
||||||
|
display: none;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
z-index: 90;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
box-shadow: 4px 4px 0 rgba(0,0,0,.45);
|
||||||
|
zoom: var(--ui-scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.is-visible {
|
||||||
|
display: block;
|
||||||
|
animation: toast-in 180ms steps(3, end), toast-buzz 700ms steps(2, end) 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-warning {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast-error {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #b00000;
|
||||||
|
text-shadow: 1px 1px 0 #000000;
|
||||||
|
border-color: #ffb0b0 #330000 #330000 #ffb0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popup-open-v10 {
|
||||||
|
from { transform: translate(-50%, -48%) scale(.97); opacity: .35; }
|
||||||
|
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
@keyframes toast-buzz { 0%, 100% { margin-right: 0; } 25% { margin-right: 2px; } 50% { margin-right: -2px; } }
|
||||||
|
|
||||||
|
@media (min-width: 1800px) {
|
||||||
|
:root { --base-font-size: 14px; --ui-scale: 1.2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2048px) {
|
||||||
|
:root { --base-font-size: 15px; --ui-scale: 1.36; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 2560px) {
|
||||||
|
:root { --base-font-size: 16px; --ui-scale: 1.58; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 3200px) {
|
||||||
|
:root { --base-font-size: 18px; --ui-scale: 1.88; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta {
|
||||||
|
min-width: 128px;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
isolation: isolate;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta.is-current-step {
|
||||||
|
animation: start-ready-rainbow-breathe 1150ms ease-in-out infinite;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 0 0 1px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta.is-current-step::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 4px;
|
||||||
|
background: linear-gradient(90deg, #ff004c, #ffcc00, #00d26a, #00a2ff, #8c48ff, #ff004c, #ffcc00);
|
||||||
|
background-size: 280% 100%;
|
||||||
|
opacity: .9;
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
animation: start-border-rainbow-slide 1850ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes start-ready-rainbow-breathe {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.02); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes start-border-rainbow-slide {
|
||||||
|
from { background-position: 0% 0%; }
|
||||||
|
to { background-position: 200% 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,48 @@
|
|||||||
.box-window {
|
.box-window {
|
||||||
width: 640px;
|
width: min(860px, calc(100vw - 36px));
|
||||||
height: 460px;
|
height: min(560px, calc(100vh - 36px));
|
||||||
|
zoom: var(--ui-scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-toolbar {
|
body.fit-window .box-window {
|
||||||
display: flex;
|
width: min(980px, calc(100vw / var(--ui-scale) - 20px));
|
||||||
|
height: min(720px, calc(100vh / var(--ui-scale) - 20px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-command-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 40px;
|
min-height: 40px;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-toolbar-button {
|
.box-toolbar-button {
|
||||||
width: 116px;
|
width: auto;
|
||||||
|
min-width: 158px;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-toolbar-button img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-address {
|
.box-address {
|
||||||
display: grid;
|
grid-column: 1;
|
||||||
grid-template-columns: 58px minmax(0, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
height: 28px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 8px 6px;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-address code {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 22px;
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-top: 1px solid #808080;
|
border-top: 1px solid #808080;
|
||||||
@@ -44,6 +50,33 @@
|
|||||||
border-right: 1px solid #dfdfdf;
|
border-right: 1px solid #dfdfdf;
|
||||||
border-bottom: 1px solid #dfdfdf;
|
border-bottom: 1px solid #dfdfdf;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-window.popup-window {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-window.popup-window.is-visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-meta {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-panel {
|
.box-panel {
|
||||||
@@ -51,6 +84,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
margin: 0 8px 8px;
|
margin: 0 8px 8px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-file-grid {
|
.box-file-grid {
|
||||||
@@ -68,7 +105,6 @@
|
|||||||
grid-template-rows: 34px 18px 28px;
|
grid-template-rows: 34px 18px 28px;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 8px 6px;
|
padding: 8px 6px;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -111,6 +147,14 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-file.has-thumbnail .box-file-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
.box-file-name,
|
.box-file-name,
|
||||||
.box-file-meta {
|
.box-file-meta {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -148,6 +192,106 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 168px;
|
||||||
|
display: none;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: 3px 3px 0 rgba(0,0,0,.35);
|
||||||
|
z-index: 95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-context-menu.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-context-menu button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
color: #000000;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-context-menu button:hover,
|
||||||
|
.box-context-menu button:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-context-menu img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 92px minmax(0, 1fr);
|
||||||
|
gap: 7px 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-grid dt {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-grid dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: min(520px, calc(100vh - 160px));
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #000000;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame.is-text {
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #00ff66;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-frame.is-text code {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.box-empty {
|
.box-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -164,6 +308,7 @@
|
|||||||
main {
|
main {
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-window {
|
.box-window {
|
||||||
@@ -171,6 +316,7 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
zoom: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box-titlebar {
|
.box-titlebar {
|
||||||
@@ -182,6 +328,15 @@
|
|||||||
height: 26px;
|
height: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-command-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-address {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.box-panel {
|
.box-panel {
|
||||||
margin: 0 6px 8px;
|
margin: 0 6px 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
501
static/css/boxes.css
Normal file
501
static/css/boxes.css
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
.boxes-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.boxes-stat-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-stat-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: rgba(255,255,255,.65);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a0a0a0;
|
||||||
|
border-bottom: 1px solid #a0a0a0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a08000;
|
||||||
|
border-bottom: 1px solid #a08000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note span {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-tag,
|
||||||
|
.boxes-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-files-panel {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-sub {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-tool-button,
|
||||||
|
.boxes-page-button,
|
||||||
|
.boxes-action-button,
|
||||||
|
.boxes-row-button {
|
||||||
|
min-width: 62px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-tool-button.is-danger,
|
||||||
|
.boxes-action-button.is-danger {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #800000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-input,
|
||||||
|
.boxes-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
height: 460px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.boxes-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
.boxes-table tbody tr.is-selected { background: #c5dcff; }
|
||||||
|
|
||||||
|
.boxes-table td {
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-col-check { width: 34px; }
|
||||||
|
.boxes-col-id { width: 190px; }
|
||||||
|
.boxes-col-status { width: 84px; }
|
||||||
|
.boxes-col-files { width: 58px; }
|
||||||
|
.boxes-col-size { width: 76px; }
|
||||||
|
.boxes-col-retention { width: 96px; }
|
||||||
|
.boxes-col-expires { width: 126px; }
|
||||||
|
.boxes-col-actions { width: 98px; }
|
||||||
|
|
||||||
|
.boxes-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-status-pill.ready { background: #def2e0; }
|
||||||
|
.boxes-status-pill.uploading { background: #fff1c9; }
|
||||||
|
.boxes-status-pill.attention { background: #ffe2bf; }
|
||||||
|
.boxes-status-pill.expired { background: #ffdcdc; }
|
||||||
|
.boxes-status-pill.consumed { background: #ead7ff; }
|
||||||
|
.boxes-status-pill.legacy { background: #ececec; }
|
||||||
|
|
||||||
|
.boxes-flags-cell,
|
||||||
|
.boxes-action-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-cell a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-empty-state {
|
||||||
|
padding: 24px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #444444;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95));
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-footer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-detail-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 84px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #c0c0c0;
|
||||||
|
border-bottom: 1px solid #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-info-item span {
|
||||||
|
min-width: 0;
|
||||||
|
color: #222222;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-action-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child > .boxes-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child > .boxes-panel > .boxes-panel-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child .boxes-table-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: auto;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #c0c0c0;
|
||||||
|
border-bottom: 1px solid #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-file-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.boxes-summary-grid,
|
||||||
|
.boxes-content-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column-side {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-toolbar-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.boxes-summary-grid,
|
||||||
|
.boxes-content-grid,
|
||||||
|
.boxes-toolbar-grid,
|
||||||
|
.boxes-action-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-hero-note,
|
||||||
|
.boxes-footer-bar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-table-wrap {
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxes-column:first-child .boxes-table-wrap {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
289
static/css/dashboard.css
Normal file
289
static/css/dashboard.css
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/* ==============================================
|
||||||
|
Dashboard-specific styles (shared with admin)
|
||||||
|
Reusable across account dashboard pages
|
||||||
|
============================================== */
|
||||||
|
|
||||||
|
/* Hero section */
|
||||||
|
.dashboard-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 330px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h2 { margin: 0 0 5px; font-size: 22px; line-height: 24px; }
|
||||||
|
.hero-copy p { margin: 0; color: #333; font-size: 13px; line-height: 15px; }
|
||||||
|
|
||||||
|
.hero-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
align-content: center;
|
||||||
|
padding: 7px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-status-row { display: flex; justify-content: space-between; gap: 8px; }
|
||||||
|
.status-ok { color: #008000; }
|
||||||
|
.status-warn { color: #8a6200; }
|
||||||
|
.status-danger { color: #800000; }
|
||||||
|
|
||||||
|
/* Stats grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 122px;
|
||||||
|
padding: 10px 11px 10px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 7px;
|
||||||
|
border-left: 7px solid #000078;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
|
||||||
|
.stat-card.is-ok::before { border-left-color: #008000; }
|
||||||
|
.stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
|
||||||
|
.stat-card.is-info::before { border-left-color: #000078; }
|
||||||
|
.stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
|
||||||
|
.stat-card.is-warning::before { border-left-color: #ffcc00; }
|
||||||
|
.stat-card.is-danger {
|
||||||
|
color: #000;
|
||||||
|
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
|
||||||
|
}
|
||||||
|
.stat-card.is-danger::before { border-left-color: #800000; }
|
||||||
|
|
||||||
|
.stat-label { margin: 0 0 6px; color: #333; font-size: 13px; line-height: 13px; font-weight: bold; }
|
||||||
|
.stat-value { margin: 0 0 7px; font-size: 32px; line-height: 32px; font-weight: bold; }
|
||||||
|
.stat-note { display: flex; gap: 4px; flex-wrap: wrap; margin: 0; color: #222; font-size: 12px; line-height: 14px; }
|
||||||
|
.stat-note-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main two-column grid */
|
||||||
|
.dashboard-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-span-2 { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
/* Dashboard body */
|
||||||
|
.dashboard-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section windows */
|
||||||
|
.section-window { min-height: 0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); }
|
||||||
|
.section-body { margin: 0 6px 6px; padding: 8px; min-height: 0; }
|
||||||
|
|
||||||
|
/* Scroll panels */
|
||||||
|
.scroll-panel { overflow: auto; background: #ffffff; border-top: 2px solid #606060; border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; }
|
||||||
|
.alerts-scroll { height: 326px; }
|
||||||
|
.boxes-scroll { height: 352px; }
|
||||||
|
.activity-scroll { height: 326px; }
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert-list { display: grid; min-width: 0; }
|
||||||
|
.alert-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
min-height: 74px;
|
||||||
|
padding: 7px;
|
||||||
|
color: #000;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.alert-row:nth-child(even) { background: #f5f8ff; }
|
||||||
|
.alert-row.is-dismissed { display: none; }
|
||||||
|
.alert-severity {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 60px;
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
}
|
||||||
|
.alert-row[data-severity="low"] .alert-severity { color: #000078; }
|
||||||
|
.alert-row[data-severity="medium"] .alert-severity { color: #8a6200; background: #ffffcc; }
|
||||||
|
.alert-row[data-severity="high"] .alert-severity { color: #ffffff; background: #800000; }
|
||||||
|
.alert-title { margin: 0 0 3px; font-weight: bold; font-size: 14px; line-height: 15px; }
|
||||||
|
.alert-desc { margin: 0 0 3px; color: #333; font-size: 12px; line-height: 14px; }
|
||||||
|
.alert-trace { margin: 0; color: #555; font-family: 'MonoCraft', 'Courier New', monospace; font-size: 10px; line-height: 13px; overflow-wrap: anywhere; }
|
||||||
|
.alert-actions { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Boxes table */
|
||||||
|
.box-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 900px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
.box-table th, .box-table td { padding: 6px 7px; border-bottom: 1px solid #dfdfdf; text-align: left; vertical-align: middle; }
|
||||||
|
.box-table th { position: sticky; top: 0; z-index: 5; background: #dfdfdf; border-bottom: 1px solid #808080; }
|
||||||
|
.box-table tr:nth-child(even) td { background: #f5f8ff; }
|
||||||
|
.box-actions { display: flex; gap: 5px; flex-wrap: nowrap; }
|
||||||
|
.box-action-button { min-width: 62px; height: 22px; padding: 0 6px; font-size: 12px; line-height: 12px; }
|
||||||
|
|
||||||
|
/* Activity */
|
||||||
|
.activity-list { display: grid; }
|
||||||
|
.activity-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid #dfdfdf;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.activity-row:nth-child(even) { background: #f5f8ff; }
|
||||||
|
.activity-time { font-weight: bold; color: #000078; }
|
||||||
|
.activity-title { margin: 0 0 2px; font-weight: bold; }
|
||||||
|
.activity-meta { margin: 0; color: #555; font-size: 12px; line-height: 13px; }
|
||||||
|
|
||||||
|
/* Modal / Popup */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
background: rgba(128, 128, 128, .42);
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
.modal-backdrop.is-visible { display: block; }
|
||||||
|
|
||||||
|
.popup-window {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(calc(-50% - 1px), -50%);
|
||||||
|
width: min(760px, calc(100vw - 24px));
|
||||||
|
max-height: min(760px, calc(100vh - 24px));
|
||||||
|
display: none;
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
.popup-window.is-visible { display: flex; animation: popup-open 160ms steps(5, end); }
|
||||||
|
@keyframes popup-open {
|
||||||
|
from { transform: translate(calc(-50% - 1px), calc(-50% + 10px)) scale(.97); opacity: .45; }
|
||||||
|
to { transform: translate(calc(-50% - 1px), -50%) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
.popup-body { margin: 0 6px 6px; padding: 10px; max-height: calc(100vh - 90px); overflow: auto; color: #000; }
|
||||||
|
.metadata-pre {
|
||||||
|
min-height: 240px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #b7ffc8;
|
||||||
|
background: #030403;
|
||||||
|
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
|
||||||
|
font-family: 'MonoCraft', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiny button (for alerts / boxes) */
|
||||||
|
.tiny-button {
|
||||||
|
min-width: 56px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
color: #000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #000000;
|
||||||
|
border-bottom: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tiny-button:hover { filter: brightness(1.06); }
|
||||||
|
|
||||||
|
/* Compact mode */
|
||||||
|
body.is-compact .dashboard-body { gap: 8px; }
|
||||||
|
body.is-compact .section-body { padding: 5px; }
|
||||||
|
body.is-compact .alerts-scroll,
|
||||||
|
body.is-compact .boxes-scroll { height: 280px; }
|
||||||
|
body.is-compact .activity-scroll { height: 280px; }
|
||||||
|
body.is-compact .alert-row { min-height: 62px; }
|
||||||
|
body.is-compact .activity-row { min-height: 42px; }
|
||||||
|
|
||||||
|
/* Responsive: medium */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.dashboard-hero { grid-template-columns: 1fr; }
|
||||||
|
.dashboard-main-grid { grid-template-columns: 1fr; }
|
||||||
|
.dashboard-span-2 { grid-column: auto; }
|
||||||
|
.alerts-scroll, .boxes-scroll { height: 310px; }
|
||||||
|
.activity-scroll { height: 310px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: small (mobile) */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dashboard-body { padding: 6px; gap: 8px; }
|
||||||
|
.stats-grid { grid-template-columns: 1fr; }
|
||||||
|
.stat-card { min-height: 112px; }
|
||||||
|
.alert-row { grid-template-columns: 1fr; min-height: 0; }
|
||||||
|
.alert-actions { justify-content: flex-start; }
|
||||||
|
.alerts-scroll, .boxes-scroll, .activity-scroll { height: 320px; }
|
||||||
|
.boxes-scroll { overflow-x: auto; }
|
||||||
|
.activity-row { grid-template-columns: 48px minmax(0, 1fr); }
|
||||||
|
.activity-row .tag { grid-column: 2; justify-self: start; }
|
||||||
|
.popup-window {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: none;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.popup-window.is-visible { animation: popup-open-mobile 150ms steps(5, end); }
|
||||||
|
@keyframes popup-open-mobile { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
.popup-body { max-height: calc(100dvh - 40px); }
|
||||||
|
}
|
||||||
133
static/css/login.css
Normal file
133
static/css/login.css
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
.login-window {
|
||||||
|
width: 420px;
|
||||||
|
height: 248px;
|
||||||
|
zoom: var(--ui-scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
flex: 1;
|
||||||
|
margin: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #dfdfdf;
|
||||||
|
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.18) 0 1px, transparent 1px 5px);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-alert p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 82px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input[readonly] {
|
||||||
|
color: #555555;
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin: 2px 0 0 90px;
|
||||||
|
color: #800000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions .win98-button {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-statusbar {
|
||||||
|
grid-template-columns: 1fr 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-window {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
zoom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-titlebar {
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
margin: 8px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
516
static/css/settings.css
Normal file
516
static/css/settings.css
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
.settings-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.settings-stat-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stat-note {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: rgba(255,255,255,.65);
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a0a0a0;
|
||||||
|
border-bottom: 1px solid #a0a0a0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 238px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-workbench {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel,
|
||||||
|
.settings-hero-panel {
|
||||||
|
min-width: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 22px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-sub {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-copy h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-copy p {
|
||||||
|
margin: 0;
|
||||||
|
color: #222222;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero-legend {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-legend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #222222;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search label {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input,
|
||||||
|
.settings-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input,
|
||||||
|
.settings-select {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button.is-active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
border-top-color: #000000;
|
||||||
|
border-left-color: #000000;
|
||||||
|
border-right-color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-count,
|
||||||
|
.settings-dirty-chip,
|
||||||
|
.settings-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-button.is-active .settings-category-count {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dirty-chip {
|
||||||
|
min-width: 78px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-dirty-chip.is-dirty {
|
||||||
|
background: #ffffcc;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-default { background: #ececec; }
|
||||||
|
.badge-env { background: #c7d8f2; }
|
||||||
|
.badge-db { background: #d2efcf; }
|
||||||
|
.badge-hard { background: #ffd9d9; }
|
||||||
|
|
||||||
|
.settings-tool-button,
|
||||||
|
.settings-mini-button,
|
||||||
|
.settings-popup-close {
|
||||||
|
min-width: 64px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-action-summary {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffcc;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #a08000;
|
||||||
|
border-bottom: 1px solid #a08000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-groups {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 700px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-title {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table-wrap {
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th,
|
||||||
|
.settings-table td {
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #dfdfdf;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.setting-row.is-locked { color: #555555; background: #efefef; }
|
||||||
|
.setting-row.is-hidden { display: none; }
|
||||||
|
.setting-row.is-invalid { background: #fff1c9; }
|
||||||
|
|
||||||
|
.setting-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-meta strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-meta code {
|
||||||
|
color: #1b325f;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
color: #444444;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
background: rgba(0,0,0,.35);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-modal-backdrop.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: min(520px, calc(100vw - 24px));
|
||||||
|
display: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #000000;
|
||||||
|
background: var(--w98-gray);
|
||||||
|
border-top: 2px solid #ffffff;
|
||||||
|
border-left: 2px solid #ffffff;
|
||||||
|
border-right: 2px solid #000000;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
box-shadow: 6px 6px 0 rgba(0,0,0,.35);
|
||||||
|
z-index: 95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-body {
|
||||||
|
padding: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup-body p,
|
||||||
|
.settings-popup-body ul {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.settings-summary-grid,
|
||||||
|
.settings-main-grid,
|
||||||
|
.settings-hero-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar-panel,
|
||||||
|
.settings-workbench {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.settings-summary-grid,
|
||||||
|
.settings-main-grid,
|
||||||
|
.settings-hero-panel {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-category-list {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-table {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
.upload-window {
|
|
||||||
width: 520px;
|
|
||||||
height: 486px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-form {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-panel {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
margin: 0 8px 8px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-dropzone {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 118px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
text-align: center;
|
|
||||||
background: #dfdfdf;
|
|
||||||
border: 1px dotted #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-dropzone.is-dragging {
|
|
||||||
background: #c7d8f2;
|
|
||||||
outline: 2px solid #000078;
|
|
||||||
outline-offset: -4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-dropzone:focus-visible {
|
|
||||||
outline: 1px dotted #000000;
|
|
||||||
outline-offset: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon {
|
|
||||||
width: 34px;
|
|
||||||
height: 30px;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 2px solid #000000;
|
|
||||||
box-shadow: inset -3px -3px 0 #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: -2px;
|
|
||||||
top: -2px;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #dfdfdf;
|
|
||||||
border-left: 2px solid #000000;
|
|
||||||
border-bottom: 2px solid #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-primary {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-secondary {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-input {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-details {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 28px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 0 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 1px solid #808080;
|
|
||||||
border-left: 1px solid #808080;
|
|
||||||
border-right: 1px solid #dfdfdf;
|
|
||||||
border-bottom: 1px solid #dfdfdf;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-detail-label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-right: 6px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-count {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-list {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
margin-top: 8px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 2px solid #808080;
|
|
||||||
border-left: 2px solid #808080;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 36px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
background: #dfdfdf;
|
|
||||||
border-top: 1px solid #808080;
|
|
||||||
border-left: 1px solid #808080;
|
|
||||||
border-right: 1px solid #ffffff;
|
|
||||||
border-bottom: 1px solid #ffffff;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result.is-hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result-label {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result-link {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #000078;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result-link.is-empty {
|
|
||||||
color: #555555;
|
|
||||||
pointer-events: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-share-button {
|
|
||||||
width: 72px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-share-button:disabled {
|
|
||||||
color: #808080;
|
|
||||||
text-shadow: 1px 1px 0 #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-empty-state {
|
|
||||||
margin: 0;
|
|
||||||
padding: 9px 8px;
|
|
||||||
color: #555555;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 22px minmax(0, 1fr) 82px;
|
|
||||||
grid-template-rows: 20px 8px;
|
|
||||||
align-items: center;
|
|
||||||
height: 36px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-bottom: 1px solid #dfdfdf;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-row:nth-child(even) {
|
|
||||||
background: #f7f7f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-row.is-uploading,
|
|
||||||
.upload-file-row.is-processing {
|
|
||||||
animation: upload-row-loading 900ms steps(2, end) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-icon {
|
|
||||||
grid-row: 1 / 3;
|
|
||||||
width: 16px;
|
|
||||||
height: 18px;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #000000;
|
|
||||||
box-shadow: inset -2px -2px 0 #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-icon::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: -1px;
|
|
||||||
right: -1px;
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
background: #dfdfdf;
|
|
||||||
border-left: 1px solid #000000;
|
|
||||||
border-bottom: 1px solid #000000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-name,
|
|
||||||
.upload-file-size {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-size {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-progress {
|
|
||||||
grid-column: 2 / 4;
|
|
||||||
grid-row: 2;
|
|
||||||
display: block;
|
|
||||||
height: 8px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 1px solid #808080;
|
|
||||||
border-left: 1px solid #808080;
|
|
||||||
border-right: 1px solid #dfdfdf;
|
|
||||||
border-bottom: 1px solid #dfdfdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-progress-bar {
|
|
||||||
display: block;
|
|
||||||
width: 0%;
|
|
||||||
height: 100%;
|
|
||||||
background: #000078;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-row.is-uploaded .upload-progress-bar {
|
|
||||||
background: #008000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-row.is-failed .upload-progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
background: #800000;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes upload-row-loading {
|
|
||||||
0% {
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-color: #e6e6e6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
height: 40px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overall {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 42px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 28px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 8px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overall-track {
|
|
||||||
height: 18px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #ffffff;
|
|
||||||
border-top: 2px solid #808080;
|
|
||||||
border-left: 2px solid #808080;
|
|
||||||
border-right: 2px solid #ffffff;
|
|
||||||
border-bottom: 2px solid #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overall-bar {
|
|
||||||
display: block;
|
|
||||||
width: 0%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #000078;
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
to right,
|
|
||||||
#000078 0,
|
|
||||||
#000078 10px,
|
|
||||||
#c0c0c0 10px,
|
|
||||||
#c0c0c0 12px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overall-percent {
|
|
||||||
min-width: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-statusbar {
|
|
||||||
grid-template-columns: 1fr 96px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
main {
|
|
||||||
display: block;
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-window {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100dvh;
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-titlebar {
|
|
||||||
height: 24px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-menu {
|
|
||||||
height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-panel {
|
|
||||||
margin: 0 6px 8px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-dropzone {
|
|
||||||
height: 126px;
|
|
||||||
min-height: 126px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-file-list {
|
|
||||||
min-height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-result {
|
|
||||||
grid-template-columns: 64px minmax(0, 1fr) 68px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
static/css/upload/actions.css
Normal file
36
static/css/upload/actions.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.upload-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta {
|
||||||
|
min-width: 128px;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
isolation: isolate;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta.is-current-step {
|
||||||
|
animation: start-ready-rainbow-breathe 1150ms ease-in-out infinite;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 0 0 1px #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-upload-cta.is-current-step::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 4px;
|
||||||
|
background: linear-gradient(90deg, #ff004c, #ffcc00, #00d26a, #00a2ff, #8c48ff, #ff004c, #ffcc00);
|
||||||
|
background-size: 280% 100%;
|
||||||
|
opacity: .9;
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
animation: start-border-rainbow-slide 1850ms linear infinite;
|
||||||
|
}
|
||||||
101
static/css/upload/dialog-content.css
Normal file
101
static/css/upload/dialog-content.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
.duplicate-list,
|
||||||
|
.quota-dialog-list {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 6px 6px 6px 28px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-dialog-summary,
|
||||||
|
.quota-note {
|
||||||
|
padding: 8px;
|
||||||
|
background: #ffffcc;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-meter-list,
|
||||||
|
.faq-list,
|
||||||
|
.shortcut-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-meter,
|
||||||
|
.faq-item,
|
||||||
|
.shortcut-list li {
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-meter-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-meter-track {
|
||||||
|
height: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-meter-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-fallback-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-fallback-text {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body .code-block {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body .code-block code {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
white-space: inherit;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #000000;
|
||||||
|
background: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
95
static/css/upload/dialogs.css
Normal file
95
static/css/upload/dialogs.css
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
background: rgba(128, 128, 128, .42);
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(780px, calc(100vw - 24px));
|
||||||
|
max-height: min(760px, calc(100vh - 24px));
|
||||||
|
display: none;
|
||||||
|
z-index: 80;
|
||||||
|
zoom: var(--ui-scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-visible {
|
||||||
|
display: flex;
|
||||||
|
animation: popup-open-v10 180ms steps(5, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-about-popup {
|
||||||
|
width: min(360px, calc(100vw - 28px));
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: calc(100vh - 90px);
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body h3 { margin: 0 0 8px; font-size: 16px; line-height: 18px; }
|
||||||
|
.popup-body h4 { margin: 14px 0 6px; font-size: 14px; line-height: 16px; }
|
||||||
|
.popup-body p { margin: 0 0 8px; }
|
||||||
|
.popup-body ul,
|
||||||
|
.popup-body ol { margin: 0 0 8px 18px; padding: 0; }
|
||||||
|
.popup-body li { margin: 0 0 4px; }
|
||||||
|
.popup-body .code-block {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 8px;
|
||||||
|
color: #00ff66;
|
||||||
|
background: #000000;
|
||||||
|
border: 0;
|
||||||
|
font-family: 'MonoCraft', 'PixelOperatorMono', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
white-space: pre;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
box-sizing: border-box;
|
||||||
|
contain: layout paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-window.is-about-popup .popup-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-popup-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-popup-content p:last-child {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-close {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
41
static/css/upload/folders.css
Normal file
41
static/css/upload/folders.css
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
.folder-icon-button {
|
||||||
|
flex: 0 0 86px;
|
||||||
|
width: 86px;
|
||||||
|
min-width: 86px;
|
||||||
|
height: 68px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 34px 1fr;
|
||||||
|
place-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
color: #000000;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon-button img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon-button:hover,
|
||||||
|
.folder-icon-button:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
background: #000078;
|
||||||
|
border: 1px dotted #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon-button-disabled {
|
||||||
|
color: #606060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon-button-disabled img {
|
||||||
|
filter: grayscale(.9);
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
43
static/css/upload/layout.css
Normal file
43
static/css/upload/layout.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.upload-main {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-wrap {
|
||||||
|
--window-height: 736px;
|
||||||
|
--side-width: 440px;
|
||||||
|
width: min(1278px, 100%);
|
||||||
|
height: min(var(--window-height), calc(100vh - 36px));
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 820px) var(--side-width);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
zoom: var(--ui-scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fit-window .desktop-wrap {
|
||||||
|
width: min(100%, calc(100vw / var(--ui-scale) - 20px));
|
||||||
|
height: min(calc(100vh / var(--ui-scale) - 20px), 900px);
|
||||||
|
max-height: none;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--side-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-window {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
309
static/css/users.css
Normal file
309
static/css/users.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.users-page-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-hero-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card {
|
||||||
|
padding: 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
|
||||||
|
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
|
||||||
|
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
|
||||||
|
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
|
||||||
|
|
||||||
|
.users-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-title span {
|
||||||
|
font-weight: normal;
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-list-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-row-two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-input,
|
||||||
|
.users-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 28px;
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
border-left: 1px solid #808080;
|
||||||
|
border-right: 1px solid #ffffff;
|
||||||
|
border-bottom: 1px solid #ffffff;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-check {
|
||||||
|
min-height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-action-button,
|
||||||
|
.users-tool-button,
|
||||||
|
.users-page-button {
|
||||||
|
min-width: 70px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-toolbar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table-wrap {
|
||||||
|
min-height: 420px;
|
||||||
|
height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
padding: 6px;
|
||||||
|
border-bottom: 1px solid #e1e1e1;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: left;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
|
||||||
|
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
|
.users-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
|
||||||
|
.users-col-check { width: 30px; }
|
||||||
|
.users-col-actions { width: 136px; }
|
||||||
|
|
||||||
|
.users-username {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-username strong {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-muted {
|
||||||
|
color: #555555;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #222222;
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #b0b0b0;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-pill.active { background: #def2e0; }
|
||||||
|
.users-pill.pending { background: #fff1c9; }
|
||||||
|
.users-pill.disabled { background: #ffdcdc; }
|
||||||
|
|
||||||
|
.users-row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-row-button {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.users-main-grid,
|
||||||
|
.users-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
.win98-window {
|
.win98-window {
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
background: var(--w98-gray);
|
background-color: #c0c0c0;
|
||||||
border-top: 2px solid #ffffff;
|
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
|
||||||
border-left: 2px solid #ffffff;
|
border-top: 1px solid #ffffff;
|
||||||
border-right: 2px solid #000000;
|
border-left: 1px solid #ffffff;
|
||||||
border-bottom: 2px solid #000000;
|
border-right: 1px solid #000000;
|
||||||
box-shadow:
|
border-bottom: 1px solid #000000;
|
||||||
inset -1px -1px 0 #808080,
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 5px 6px 0 rgba(0,0,0,.5);
|
||||||
inset 1px 1px 0 #dfdfdf;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-titlebar {
|
.win98-titlebar {
|
||||||
@@ -18,58 +16,79 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
padding: 2px 3px 2px 6px;
|
padding: 2px 3px 2px 6px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background: var(--w98-blue-gradient);
|
background: var(--w98-blue-gradient);
|
||||||
|
background-size: 240% 100%;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.35), inset 0 -1px 0 rgba(0,0,0,.35);
|
||||||
|
user-select: none;
|
||||||
|
animation: titlebar-center-drift 34s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-titlebar h1 {
|
@keyframes titlebar-center-drift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
100% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-titlebar h1,
|
||||||
|
.win98-titlebar h2 {
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 14px;
|
line-height: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.win98-titlebar-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.win98-titlebar-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
.win98-window-controls {
|
.win98-window-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-control {
|
.win98-control {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
color: #000000;
|
color: #000000;
|
||||||
background: var(--w98-gray);
|
background: var(--w98-gray);
|
||||||
border-top: 1px solid #ffffff;
|
border-top: 1px solid #ffffff;
|
||||||
border-left: 1px solid #ffffff;
|
border-left: 1px solid #ffffff;
|
||||||
border-right: 1px solid #000000;
|
border-right: 1px solid #000000;
|
||||||
border-bottom: 1px solid #000000;
|
border-bottom: 1px solid #000000;
|
||||||
box-shadow:
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
|
||||||
inset -1px -1px 0 #808080,
|
font-family: inherit;
|
||||||
inset 1px 1px 0 #dfdfdf;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-menu {
|
.win98-minimize {
|
||||||
display: flex;
|
align-items: start;
|
||||||
align-items: center;
|
padding-top: 0;
|
||||||
gap: 18px;
|
line-height: 8px;
|
||||||
height: 22px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-panel {
|
.win98-panel {
|
||||||
box-sizing: border-box;
|
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-top: 2px solid #808080;
|
border-top: 2px solid #808080;
|
||||||
border-left: 2px solid #808080;
|
border-left: 2px solid #808080;
|
||||||
@@ -77,51 +96,10 @@
|
|||||||
border-bottom: 2px solid #ffffff;
|
border-bottom: 2px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.win98-button {
|
|
||||||
width: 92px;
|
|
||||||
height: 28px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 10px;
|
|
||||||
color: #000000;
|
|
||||||
background: var(--w98-gray);
|
|
||||||
border-top: 2px solid #ffffff;
|
|
||||||
border-left: 2px solid #ffffff;
|
|
||||||
border-right: 2px solid #000000;
|
|
||||||
border-bottom: 2px solid #000000;
|
|
||||||
box-shadow:
|
|
||||||
inset -1px -1px 0 #808080,
|
|
||||||
inset 1px 1px 0 #dfdfdf;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 13px;
|
|
||||||
text-align: center;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.win98-button:active {
|
|
||||||
border-top-color: #000000;
|
|
||||||
border-left-color: #000000;
|
|
||||||
border-right-color: #ffffff;
|
|
||||||
border-bottom-color: #ffffff;
|
|
||||||
box-shadow:
|
|
||||||
inset -1px -1px 0 #dfdfdf,
|
|
||||||
inset 1px 1px 0 #808080;
|
|
||||||
padding: 1px 9px 0 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.win98-button:focus-visible {
|
|
||||||
outline: 1px dotted #000000;
|
|
||||||
outline-offset: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.win98-statusbar {
|
.win98-statusbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0 4px 4px;
|
padding: 0 4px 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 12px;
|
line-height: 12px;
|
||||||
@@ -132,7 +110,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -141,3 +118,91 @@
|
|||||||
border-right: 1px solid #ffffff;
|
border-right: 1px solid #ffffff;
|
||||||
border-bottom: 1px solid #ffffff;
|
border-bottom: 1px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.win98-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Raised panel - appears to sit above the surface */
|
||||||
|
.raised-panel {
|
||||||
|
background: #dfdfdf;
|
||||||
|
border-top: 1px solid #ffffff;
|
||||||
|
border-left: 1px solid #ffffff;
|
||||||
|
border-right: 1px solid #808080;
|
||||||
|
border-bottom: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sunken panel - appears to be inset into the surface */
|
||||||
|
.sunken-panel {
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll panel - used for scrollable content areas within windows */
|
||||||
|
.scroll-panel {
|
||||||
|
overflow: auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 2px solid #606060;
|
||||||
|
border-left: 2px solid #606060;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meter track for progress bars */
|
||||||
|
.meter-track {
|
||||||
|
display: block;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 9px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.06) 0 1px, transparent 1px 18px);
|
||||||
|
border-top: 2px solid #808080;
|
||||||
|
border-left: 2px solid #808080;
|
||||||
|
border-right: 2px solid #ffffff;
|
||||||
|
border-bottom: 2px solid #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: var(--meter, 0%);
|
||||||
|
background-color: #000078;
|
||||||
|
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.13) 0 1px, transparent 1px 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag styles for status indicators */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 17px;
|
||||||
|
margin: 1px 2px 1px 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #000000;
|
||||||
|
background: #dfdfdf;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
box-shadow: inset 1px 1px 0 #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.ok { color: #008000; background: #eeffee; }
|
||||||
|
.tag.info { color: #000078; background: #edf4ff; }
|
||||||
|
.tag.warn { color: #8a6200; background: #ffffcc; }
|
||||||
|
.tag.danger { color: #ffffff; background: #800000; }
|
||||||
|
|
||||||
|
/* Titlebar animation - gradient drift */
|
||||||
|
@keyframes titlebar-drift {
|
||||||
|
from { background-position: 0% 50%; }
|
||||||
|
to { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 235 B |
Binary file not shown.
|
Before Width: | Height: | Size: 230 B |
216
static/js/admin/alerts.js
Normal file
216
static/js/admin/alerts.js
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||||
|
close() {
|
||||||
|
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||||
|
item.classList.remove("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toast = document.getElementById("toast");
|
||||||
|
const searchInput = document.getElementById("search-input");
|
||||||
|
const severityFilter = document.getElementById("severity-filter");
|
||||||
|
const statusFilter = document.getElementById("status-filter");
|
||||||
|
const sourceFilter = document.getElementById("source-filter");
|
||||||
|
const sortFilter = document.getElementById("sort-filter");
|
||||||
|
const alertsBody = document.getElementById("alerts-body");
|
||||||
|
const selectedCountEl = document.getElementById("selected-count");
|
||||||
|
const openCountEl = document.querySelector("[data-open-count]");
|
||||||
|
const highCountEl = document.querySelector("[data-high-count]");
|
||||||
|
const ackCountEl = document.querySelector("[data-ack-count]");
|
||||||
|
const closedCountEl = document.querySelector("[data-closed-count]");
|
||||||
|
const selectAll = document.getElementById("select-all");
|
||||||
|
|
||||||
|
const detailEls = {
|
||||||
|
title: document.getElementById("detail-title"),
|
||||||
|
severity: document.getElementById("detail-severity"),
|
||||||
|
status: document.getElementById("detail-status"),
|
||||||
|
code: document.getElementById("detail-code"),
|
||||||
|
trace: document.getElementById("detail-trace"),
|
||||||
|
time: document.getElementById("detail-time"),
|
||||||
|
description: document.getElementById("detail-description"),
|
||||||
|
metadata: document.getElementById("detail-metadata")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
|
||||||
|
|
||||||
|
function showToast(message, type = "info", duration = 1800) {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toast, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toast) return;
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add("is-visible");
|
||||||
|
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allRows() {
|
||||||
|
return Array.from(alertsBody.querySelectorAll("tr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleRows() {
|
||||||
|
return allRows().filter((row) => row.style.display !== "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedRows() {
|
||||||
|
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedCount() {
|
||||||
|
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryCounts() {
|
||||||
|
const rows = visibleRows();
|
||||||
|
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
|
||||||
|
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
|
||||||
|
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
|
||||||
|
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetails(row) {
|
||||||
|
if (!row) return;
|
||||||
|
allRows().forEach((item) => item.classList.remove("is-selected"));
|
||||||
|
row.classList.add("is-selected");
|
||||||
|
detailEls.title.textContent = row.dataset.title || "";
|
||||||
|
detailEls.severity.textContent = row.dataset.severity || "";
|
||||||
|
detailEls.status.textContent = row.dataset.status || "";
|
||||||
|
detailEls.code.textContent = row.dataset.code || "";
|
||||||
|
detailEls.trace.textContent = row.dataset.trace || "";
|
||||||
|
detailEls.time.textContent = row.dataset.time || "";
|
||||||
|
detailEls.description.textContent = row.dataset.description || "";
|
||||||
|
try {
|
||||||
|
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
detailEls.metadata.textContent = row.dataset.metadata || "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const search = searchInput.value.trim().toLowerCase();
|
||||||
|
const severity = severityFilter.value;
|
||||||
|
const status = statusFilter.value;
|
||||||
|
const group = sourceFilter.value;
|
||||||
|
|
||||||
|
allRows().forEach((row) => {
|
||||||
|
const haystack = [
|
||||||
|
row.dataset.title,
|
||||||
|
row.dataset.description,
|
||||||
|
row.dataset.code,
|
||||||
|
row.dataset.trace,
|
||||||
|
row.dataset.group
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
const matchesSearch = !search || haystack.includes(search);
|
||||||
|
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
|
||||||
|
const matchesStatus = status === "all" || row.dataset.status === status;
|
||||||
|
const matchesGroup = group === "all" || row.dataset.group === group;
|
||||||
|
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
const order = { high: 3, medium: 2, low: 1 };
|
||||||
|
visibleRows().sort((a, b) => {
|
||||||
|
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
|
||||||
|
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
|
||||||
|
return Number(b.dataset.id) - Number(a.dataset.id);
|
||||||
|
}).forEach((row) => alertsBody.appendChild(row));
|
||||||
|
|
||||||
|
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
|
||||||
|
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
|
||||||
|
updateSelectedCount();
|
||||||
|
updateSummaryCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowStatus(row, nextStatus) {
|
||||||
|
row.dataset.status = nextStatus;
|
||||||
|
const statusCell = row.children[3]?.querySelector(".alerts-pill");
|
||||||
|
if (!statusCell) return;
|
||||||
|
statusCell.className = `alerts-pill ${nextStatus}`;
|
||||||
|
statusCell.textContent = nextStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSelectedStatus(nextStatus) {
|
||||||
|
const rows = selectedRows();
|
||||||
|
if (!rows.length) {
|
||||||
|
showToast("Select one or more alerts first", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
setRowStatus(row, nextStatus);
|
||||||
|
row.querySelector(".row-check").checked = false;
|
||||||
|
});
|
||||||
|
if (selectAll) selectAll.checked = false;
|
||||||
|
updateSelectedCount();
|
||||||
|
updateSummaryCounts();
|
||||||
|
|
||||||
|
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
|
||||||
|
if (currentRow) updateDetails(currentRow);
|
||||||
|
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandMessages = {
|
||||||
|
refresh: "Alerts refreshed in mock view",
|
||||||
|
export: "Visible alerts exported in mock view",
|
||||||
|
"copy-meta": "Metadata copied in mock view",
|
||||||
|
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
|
||||||
|
"help-meta": "Metadata explains why the alert happened and includes extra context."
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "ack":
|
||||||
|
changeSelectedStatus("acked");
|
||||||
|
return;
|
||||||
|
case "close":
|
||||||
|
changeSelectedStatus("closed");
|
||||||
|
return;
|
||||||
|
case "open-only":
|
||||||
|
statusFilter.value = "open";
|
||||||
|
applyFilters();
|
||||||
|
showToast("Showing open alerts only");
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
showToast(commandMessages[command] || `Mock action: ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||||
|
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
|
||||||
|
});
|
||||||
|
|
||||||
|
allRows().forEach((row) => {
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest("button") || event.target.closest("input")) return;
|
||||||
|
updateDetails(row);
|
||||||
|
});
|
||||||
|
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
|
||||||
|
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll?.addEventListener("change", () => {
|
||||||
|
visibleRows().forEach((row) => {
|
||||||
|
const checkbox = row.querySelector(".row-check");
|
||||||
|
if (checkbox) checkbox.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
menuController.close();
|
||||||
|
runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") menuController.close();
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("refresh");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
updateDetails(allRows()[0]);
|
||||||
|
})();
|
||||||
526
static/js/admin/boxes.js
Normal file
526
static/js/admin/boxes.js
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||||
|
close() {
|
||||||
|
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||||
|
item.classList.remove("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
const dataNode = document.getElementById("boxes-data");
|
||||||
|
const tableBody = document.getElementById("boxes-table-body");
|
||||||
|
const emptyState = document.getElementById("boxes-empty-state");
|
||||||
|
const searchInput = document.getElementById("boxes-search");
|
||||||
|
const statusFilter = document.getElementById("boxes-status-filter");
|
||||||
|
const flagFilter = document.getElementById("boxes-flag-filter");
|
||||||
|
const sortFilter = document.getElementById("boxes-sort");
|
||||||
|
const pageSizeFilter = document.getElementById("boxes-page-size");
|
||||||
|
const selectAll = document.getElementById("boxes-select-all");
|
||||||
|
const prevPageButton = document.getElementById("boxes-prev-page");
|
||||||
|
const nextPageButton = document.getElementById("boxes-next-page");
|
||||||
|
const pageLabel = document.getElementById("boxes-page-label");
|
||||||
|
const rangeLabel = document.getElementById("boxes-range-label");
|
||||||
|
const selectedLabel = document.getElementById("boxes-selected-label");
|
||||||
|
const footerSummary = document.getElementById("boxes-footer-summary");
|
||||||
|
const detailFileList = document.getElementById("detail-file-list");
|
||||||
|
|
||||||
|
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
|
||||||
|
|
||||||
|
const statEls = {
|
||||||
|
total: document.querySelector("[data-stat-total]"),
|
||||||
|
ready: document.querySelector("[data-stat-ready]"),
|
||||||
|
uploading: document.querySelector("[data-stat-uploading]"),
|
||||||
|
expired: document.querySelector("[data-stat-expired]")
|
||||||
|
};
|
||||||
|
|
||||||
|
const detailEls = {
|
||||||
|
boxId: document.getElementById("detail-box-id"),
|
||||||
|
status: document.getElementById("detail-status"),
|
||||||
|
created: document.getElementById("detail-created"),
|
||||||
|
expires: document.getElementById("detail-expires"),
|
||||||
|
retention: document.getElementById("detail-retention"),
|
||||||
|
files: document.getElementById("detail-files"),
|
||||||
|
size: document.getElementById("detail-size"),
|
||||||
|
flags: document.getElementById("detail-flags"),
|
||||||
|
open: document.getElementById("detail-open"),
|
||||||
|
zip: document.getElementById("detail-zip")
|
||||||
|
};
|
||||||
|
|
||||||
|
function showToast(message, type = "info", duration = 2200) {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toastTarget) return;
|
||||||
|
toastTarget.textContent = message;
|
||||||
|
toastTarget.classList.add("is-visible");
|
||||||
|
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseData() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(dataNode.textContent || "[]");
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
boxes: parseData(),
|
||||||
|
selected: new Set(),
|
||||||
|
activeId: null,
|
||||||
|
page: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageSize() {
|
||||||
|
return Number(pageSizeFilter.value || 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allBoxes() {
|
||||||
|
return state.boxes.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBoxes(boxes) {
|
||||||
|
const sorted = boxes.slice();
|
||||||
|
switch (sortFilter.value) {
|
||||||
|
case "name":
|
||||||
|
sorted.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
break;
|
||||||
|
case "largest":
|
||||||
|
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
|
||||||
|
break;
|
||||||
|
case "expires":
|
||||||
|
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSizeLabel(left, right) {
|
||||||
|
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeLabelToBytes(label) {
|
||||||
|
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
const unit = match[2].toUpperCase();
|
||||||
|
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
|
||||||
|
return value * (map[unit] || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareExpiry(left, right) {
|
||||||
|
if (!left && !right) return 0;
|
||||||
|
if (!left) return 1;
|
||||||
|
if (!right) return -1;
|
||||||
|
return left.localeCompare(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredBoxes() {
|
||||||
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
|
const status = statusFilter.value;
|
||||||
|
const flag = flagFilter.value;
|
||||||
|
|
||||||
|
return sortBoxes(allBoxes().filter((box) => {
|
||||||
|
const matchesSearch = !query || String(box.search_text || "").includes(query);
|
||||||
|
const matchesStatus = status === "all" || box.status === status;
|
||||||
|
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
|
||||||
|
return matchesSearch && matchesStatus && matchesFlag;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedBoxes(boxes) {
|
||||||
|
const size = pageSize();
|
||||||
|
const pages = Math.max(1, Math.ceil(boxes.length / size));
|
||||||
|
if (state.page > pages) state.page = pages;
|
||||||
|
if (state.page < 1) state.page = 1;
|
||||||
|
const start = (state.page - 1) * size;
|
||||||
|
return {
|
||||||
|
items: boxes.slice(start, start + size),
|
||||||
|
start,
|
||||||
|
pages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedBoxes() {
|
||||||
|
return allBoxes().filter((box) => state.selected.has(box.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentActiveBox() {
|
||||||
|
const boxes = allBoxes();
|
||||||
|
return boxes.find((box) => box.id === state.activeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureActiveBox(filtered) {
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
state.activeId = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!filtered.some((box) => box.id === state.activeId)) {
|
||||||
|
state.activeId = filtered[0].id;
|
||||||
|
}
|
||||||
|
return filtered.find((box) => box.id === state.activeId) || filtered[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(filtered) {
|
||||||
|
const total = filtered.length;
|
||||||
|
const ready = filtered.filter((box) => box.status === "ready").length;
|
||||||
|
const uploading = filtered.filter((box) => box.status === "uploading").length;
|
||||||
|
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
|
||||||
|
statEls.total.textContent = String(total);
|
||||||
|
statEls.ready.textContent = String(ready);
|
||||||
|
statEls.uploading.textContent = String(uploading);
|
||||||
|
statEls.expired.textContent = String(expired);
|
||||||
|
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const active = ensureActiveBox(filtered);
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
|
||||||
|
tableBody.innerHTML = "";
|
||||||
|
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
|
||||||
|
emptyState.hidden = page.items.length !== 0;
|
||||||
|
|
||||||
|
const startIndex = filtered.length ? page.start + 1 : 0;
|
||||||
|
const endIndex = page.start + page.items.length;
|
||||||
|
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
|
||||||
|
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
|
||||||
|
prevPageButton.disabled = state.page <= 1;
|
||||||
|
nextPageButton.disabled = state.page >= page.pages;
|
||||||
|
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||||
|
|
||||||
|
renderSummary(filtered);
|
||||||
|
renderDetails(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(box) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
if (box.id === state.activeId) row.classList.add("is-selected");
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
|
||||||
|
<td title="${escapeAttr(box.id)}">${box.id}</td>
|
||||||
|
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
|
||||||
|
<td>${box.complete_files}/${box.file_count}</td>
|
||||||
|
<td>${box.total_size_label}</td>
|
||||||
|
<td>${box.retention_label || "Not set"}</td>
|
||||||
|
<td>${box.expires_at_label || "Not set"}</td>
|
||||||
|
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
|
||||||
|
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.addEventListener("click", (event) => {
|
||||||
|
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
|
||||||
|
state.activeId = box.id;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
state.selected.add(box.id);
|
||||||
|
} else {
|
||||||
|
state.selected.delete(box.id);
|
||||||
|
}
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
syncSelectAllForPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
|
||||||
|
state.activeId = box.id;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlags(flags) {
|
||||||
|
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
|
||||||
|
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRowActions(box) {
|
||||||
|
const parts = [
|
||||||
|
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
|
||||||
|
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
|
||||||
|
];
|
||||||
|
if (box.zip_available && box.zip_url) {
|
||||||
|
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
|
||||||
|
}
|
||||||
|
return parts.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(box) {
|
||||||
|
if (!box) {
|
||||||
|
detailEls.boxId.textContent = "-";
|
||||||
|
detailEls.status.textContent = "-";
|
||||||
|
detailEls.created.textContent = "-";
|
||||||
|
detailEls.expires.textContent = "-";
|
||||||
|
detailEls.retention.textContent = "-";
|
||||||
|
detailEls.files.textContent = "-";
|
||||||
|
detailEls.size.textContent = "-";
|
||||||
|
detailEls.flags.textContent = "-";
|
||||||
|
detailEls.open.href = "#";
|
||||||
|
detailEls.zip.href = "#";
|
||||||
|
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||||
|
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailEls.boxId.textContent = box.id;
|
||||||
|
detailEls.status.textContent = box.status_label;
|
||||||
|
detailEls.created.textContent = box.created_at_label || "Not set";
|
||||||
|
detailEls.expires.textContent = box.expires_at_label || "Not set";
|
||||||
|
detailEls.retention.textContent = box.retention_label || "Not set";
|
||||||
|
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
|
||||||
|
detailEls.size.textContent = box.total_size_label;
|
||||||
|
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
|
||||||
|
detailEls.open.href = box.open_url || "#";
|
||||||
|
|
||||||
|
if (box.zip_available && box.zip_url) {
|
||||||
|
detailEls.zip.href = box.zip_url;
|
||||||
|
detailEls.zip.removeAttribute("aria-disabled");
|
||||||
|
detailEls.zip.style.pointerEvents = "";
|
||||||
|
detailEls.zip.style.opacity = "";
|
||||||
|
} else {
|
||||||
|
detailEls.zip.href = "#";
|
||||||
|
detailEls.zip.setAttribute("aria-disabled", "true");
|
||||||
|
detailEls.zip.style.pointerEvents = "none";
|
||||||
|
detailEls.zip.style.opacity = ".55";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFiles(box.files || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiles(files) {
|
||||||
|
if (!files.length) {
|
||||||
|
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailFileList.innerHTML = files.map((file) => `
|
||||||
|
<div class="boxes-file-card">
|
||||||
|
<div class="boxes-file-row">
|
||||||
|
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
|
||||||
|
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="boxes-file-meta">
|
||||||
|
<span>${escapeHtml(file.size_label || "0 B")}</span>
|
||||||
|
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
|
||||||
|
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelectAllForPage() {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
|
||||||
|
selectedLabel.textContent = `Selected: ${state.selected.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
searchInput.value = "";
|
||||||
|
statusFilter.value = "all";
|
||||||
|
flagFilter.value = "all";
|
||||||
|
sortFilter.value = "newest";
|
||||||
|
pageSizeFilter.value = "10";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBulkAction(action, ids, deltaSeconds = 0) {
|
||||||
|
if (!ids.length) {
|
||||||
|
showToast("Select one or more boxes first", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/boxes/actions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = payload.error || payload.message || "Action failed";
|
||||||
|
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
|
||||||
|
showToast(`${message}${warning}`, "error", 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
|
||||||
|
state.selected.clear();
|
||||||
|
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
|
||||||
|
state.activeId = null;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
let message = payload.message || "Action complete";
|
||||||
|
if (Array.isArray(payload.warnings) && payload.warnings.length) {
|
||||||
|
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
|
||||||
|
}
|
||||||
|
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
|
||||||
|
} catch (_) {
|
||||||
|
showToast("Network error while updating boxes", "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIDsOrActive() {
|
||||||
|
if (state.selected.size) return Array.from(state.selected);
|
||||||
|
const active = currentActiveBox();
|
||||||
|
return active ? [active.id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "refresh":
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
case "export":
|
||||||
|
exportVisibleCSV();
|
||||||
|
showToast("Visible boxes exported");
|
||||||
|
return;
|
||||||
|
case "status-ready":
|
||||||
|
statusFilter.value = "ready";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
return;
|
||||||
|
case "status-expired":
|
||||||
|
statusFilter.value = "expired";
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
return;
|
||||||
|
case "clear-filters":
|
||||||
|
clearFilters();
|
||||||
|
showToast("Filters cleared");
|
||||||
|
return;
|
||||||
|
case "expire":
|
||||||
|
case "active-expire":
|
||||||
|
await runBulkAction("expire", selectedIDsOrActive());
|
||||||
|
return;
|
||||||
|
case "extend-day":
|
||||||
|
case "active-extend-day":
|
||||||
|
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
|
||||||
|
return;
|
||||||
|
case "extend-week":
|
||||||
|
case "active-extend-week":
|
||||||
|
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
|
||||||
|
return;
|
||||||
|
case "delete":
|
||||||
|
case "active-delete":
|
||||||
|
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
||||||
|
await runBulkAction("delete", selectedIDsOrActive());
|
||||||
|
return;
|
||||||
|
case "help-scope":
|
||||||
|
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
||||||
|
return;
|
||||||
|
case "help-flags":
|
||||||
|
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
showToast(`Unknown command: ${command}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportVisibleCSV() {
|
||||||
|
const rows = filteredBoxes().map((box) => ([
|
||||||
|
box.id,
|
||||||
|
box.status_label,
|
||||||
|
box.file_count,
|
||||||
|
box.total_size_label,
|
||||||
|
box.retention_label,
|
||||||
|
box.expires_at_label,
|
||||||
|
(box.flags || []).join("|")
|
||||||
|
]));
|
||||||
|
const csv = [
|
||||||
|
["box_id", "status", "files", "size", "retention", "expires", "flags"],
|
||||||
|
...rows
|
||||||
|
].map((row) => row.map(csvCell).join(",")).join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "warpbox-boxes.csv";
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value) {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHtml(value).replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
|
||||||
|
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pageSizeFilter.addEventListener("change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll?.addEventListener("change", () => {
|
||||||
|
const filtered = filteredBoxes();
|
||||||
|
const page = pagedBoxes(filtered);
|
||||||
|
page.items.forEach((box) => {
|
||||||
|
if (selectAll.checked) state.selected.add(box.id);
|
||||||
|
else state.selected.delete(box.id);
|
||||||
|
});
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
prevPageButton?.addEventListener("click", () => {
|
||||||
|
state.page -= 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPageButton?.addEventListener("click", () => {
|
||||||
|
state.page += 1;
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
menuController.close();
|
||||||
|
await runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", async (event) => {
|
||||||
|
if (event.key === "Escape") menuController.close();
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
await runCommand("refresh");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.boxes.length > 0) {
|
||||||
|
state.activeId = state.boxes[0].id;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
})();
|
||||||
201
static/js/admin/dashboard.js
Normal file
201
static/js/admin/dashboard.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
||||||
|
close() {
|
||||||
|
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||||
|
item.classList.remove("is-open");
|
||||||
|
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const toast = document.getElementById("toast");
|
||||||
|
const statusText = document.getElementById("statusText");
|
||||||
|
const modal = document.querySelector("[data-alert-modal]");
|
||||||
|
const backdrop = document.querySelector("[data-modal-backdrop]");
|
||||||
|
const modalTitle = document.getElementById("modalTitle");
|
||||||
|
const modalMeta = document.getElementById("modalMeta");
|
||||||
|
const alertCountValue = document.getElementById("alertCountValue");
|
||||||
|
const alertStatNote = document.getElementById("alertStatNote");
|
||||||
|
const alertsCard = document.getElementById("alertsCard");
|
||||||
|
const topAlertChip = document.getElementById("topAlertChip");
|
||||||
|
const topTaskbar = document.querySelector(".admin-taskbar");
|
||||||
|
|
||||||
|
if (!statusText || !alertsCard || !topAlertChip) return;
|
||||||
|
|
||||||
|
function showToast(message, type = "info") {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toast });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toast) return;
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add("is-visible");
|
||||||
|
window.clearTimeout(showToast.timer);
|
||||||
|
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
statusText.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(title, meta) {
|
||||||
|
if (!modal || !backdrop || !modalTitle || !modalMeta) return;
|
||||||
|
modalTitle.textContent = title;
|
||||||
|
modalMeta.textContent = meta;
|
||||||
|
modal.classList.add("is-visible");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
backdrop.classList.add("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal?.classList.remove("is-visible");
|
||||||
|
modal?.setAttribute("aria-hidden", "true");
|
||||||
|
backdrop?.classList.remove("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleAlertRows() {
|
||||||
|
return Array.from(document.querySelectorAll(".alert-row")).filter((row) => !row.classList.contains("is-dismissed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStickyHeader() {
|
||||||
|
topTaskbar?.classList.toggle("is-scrolled", window.scrollY > 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAlertSummary() {
|
||||||
|
const rows = visibleAlertRows();
|
||||||
|
const counts = rows.reduce((acc, row) => {
|
||||||
|
const severity = row.dataset.severity || "low";
|
||||||
|
acc[severity] = (acc[severity] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, { high: 0, medium: 0, low: 0 });
|
||||||
|
const score = counts.high * 5 + counts.medium * 2 + counts.low;
|
||||||
|
const total = rows.length;
|
||||||
|
const stateClass = counts.high > 0 || score >= 12 ? "is-danger" : counts.medium >= 2 || score >= 5 ? "is-warning" : total > 0 ? "is-info" : "is-ok";
|
||||||
|
|
||||||
|
alertsCard.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
|
||||||
|
alertsCard.classList.add(stateClass);
|
||||||
|
topAlertChip.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
|
||||||
|
topAlertChip.classList.add(stateClass);
|
||||||
|
if (alertCountValue) alertCountValue.textContent = String(total);
|
||||||
|
topAlertChip.textContent = total === 0 ? "OK no alerts" : `! ${total} alerts`;
|
||||||
|
if (alertStatNote) {
|
||||||
|
alertStatNote.innerHTML = total === 0
|
||||||
|
? '<span class="stat-note-pill">all clear</span>'
|
||||||
|
: `<span class="stat-note-pill">${counts.high} high</span><span class="stat-note-pill">${counts.medium} medium</span><span class="stat-note-pill">${counts.low} low</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(id) {
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!target) return;
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
setStatus(`Focused ${id.replace("-", " ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandMessages = {
|
||||||
|
refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.",
|
||||||
|
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.",
|
||||||
|
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.",
|
||||||
|
"compact-mode": "Toggled compact density.",
|
||||||
|
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.",
|
||||||
|
"show-all-alerts": "TO-DO: navigate to /admin/alerts.",
|
||||||
|
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.",
|
||||||
|
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
|
||||||
|
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
|
||||||
|
"dismiss-low-alerts": "Closed visible low-severity alerts in this mock.",
|
||||||
|
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
|
||||||
|
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
|
||||||
|
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
|
||||||
|
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
|
||||||
|
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
|
||||||
|
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
|
||||||
|
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
|
||||||
|
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCommand(command) {
|
||||||
|
if (command === "compact-mode") document.body.classList.toggle("is-compact");
|
||||||
|
if (command === "dismiss-low-alerts") {
|
||||||
|
document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed"));
|
||||||
|
updateAlertSummary();
|
||||||
|
}
|
||||||
|
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
|
||||||
|
if (command === "show-all-alerts") window.location.hash = "alerts";
|
||||||
|
|
||||||
|
const message = commandMessages[command] || `Command: ${command}`;
|
||||||
|
showToast(message);
|
||||||
|
setStatus(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
menuController.close();
|
||||||
|
runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-scroll-to]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
menuController.close();
|
||||||
|
scrollToSection(button.dataset.scrollTo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-view-meta]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const row = button.closest(".alert-row");
|
||||||
|
const title = row?.dataset.alertTitle || "Alert Metadata";
|
||||||
|
let meta = row?.dataset.alertMeta || "{}";
|
||||||
|
try {
|
||||||
|
meta = JSON.stringify(JSON.parse(meta), null, 2);
|
||||||
|
} catch (_) {
|
||||||
|
meta = row?.dataset.alertMeta || "{}";
|
||||||
|
}
|
||||||
|
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-dismiss-alert]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const row = button.closest(".alert-row");
|
||||||
|
row?.classList.add("is-dismissed");
|
||||||
|
updateAlertSummary();
|
||||||
|
showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`);
|
||||||
|
setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
|
||||||
|
backdrop?.addEventListener("click", closeModal);
|
||||||
|
topAlertChip.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSection("alerts");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("scroll", updateStickyHeader, { passive: true });
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
menuController.close();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("refresh");
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key.toLowerCase() === "a") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSection("alerts");
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key.toLowerCase() === "b") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSection("recent-boxes");
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key.toLowerCase() === "r") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSection("recent-activity");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateAlertSummary();
|
||||||
|
updateStickyHeader();
|
||||||
|
})();
|
||||||
459
static/js/admin/settings.js
Normal file
459
static/js/admin/settings.js
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
|
const rowsNode = document.getElementById("settings-rows");
|
||||||
|
const searchInput = document.getElementById("settingsSearch");
|
||||||
|
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
|
||||||
|
const groups = Array.from(document.querySelectorAll(".settings-group"));
|
||||||
|
const saveButton = document.getElementById("saveButton");
|
||||||
|
const exportButton = document.getElementById("exportButton");
|
||||||
|
const importButton = document.getElementById("importButton");
|
||||||
|
const resetButton = document.getElementById("resetButton");
|
||||||
|
const importInput = document.getElementById("settingsImportInput");
|
||||||
|
const dirtyChip = document.getElementById("dirtyChip");
|
||||||
|
const actionSummary = document.getElementById("actionSummary");
|
||||||
|
const visibleCount = document.getElementById("visibleCount");
|
||||||
|
const editableCount = document.getElementById("editableCount");
|
||||||
|
const unsavedCount = document.getElementById("unsavedCount");
|
||||||
|
const lockedCount = document.getElementById("lockedCount");
|
||||||
|
const statusLeft = document.getElementById("statusLeft");
|
||||||
|
const statusMiddle = document.getElementById("statusMiddle");
|
||||||
|
const statusRight = document.getElementById("statusRight");
|
||||||
|
const popupClose = document.getElementById("doc-popup-close");
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
|
||||||
|
if (!rowsNode || !searchInput || !saveButton) return;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
currentCategory: "all",
|
||||||
|
showChangedOnly: false,
|
||||||
|
showLockedOnly: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseRows() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rowsNode.textContent || "[]");
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowData = parseRows().reduce((map, row) => {
|
||||||
|
map[row.key] = row;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
|
||||||
|
element: row,
|
||||||
|
input: row.querySelector(".setting-input"),
|
||||||
|
hint: row.querySelector('[data-role="hint"]'),
|
||||||
|
badge: row.querySelector('[data-role="source-badge"]'),
|
||||||
|
key: row.dataset.key,
|
||||||
|
label: row.dataset.label,
|
||||||
|
category: row.dataset.category,
|
||||||
|
envName: row.dataset.envName,
|
||||||
|
type: row.dataset.type,
|
||||||
|
minimum: Number(row.dataset.minimum || 0),
|
||||||
|
locked: row.classList.contains("is-locked")
|
||||||
|
}));
|
||||||
|
|
||||||
|
function showToast(message, type = "info", duration = 2400) {
|
||||||
|
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue(row) {
|
||||||
|
if (!row.input) return row.element.dataset.original || "";
|
||||||
|
return String(row.input.value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(row) {
|
||||||
|
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRow(row) {
|
||||||
|
if (row.locked || !row.input) {
|
||||||
|
row.element.classList.remove("is-invalid");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = currentValue(row);
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
if (row.type === "size_gb") {
|
||||||
|
if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false;
|
||||||
|
else if (Number(value) < row.minimum) valid = false;
|
||||||
|
} else if (row.type === "int" || row.type === "int64") {
|
||||||
|
if (!/^\d+$/.test(value)) valid = false;
|
||||||
|
else if (Number(value) < row.minimum) valid = false;
|
||||||
|
} else if (row.type === "bool") {
|
||||||
|
valid = value === "true" || value === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
row.element.classList.toggle("is-invalid", !valid);
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowMatchesSearch(row) {
|
||||||
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
|
if (!query) return true;
|
||||||
|
const data = [
|
||||||
|
row.label,
|
||||||
|
row.envName,
|
||||||
|
row.element.dataset.description,
|
||||||
|
row.key
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
return data.includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
let visible = 0;
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
let groupVisible = 0;
|
||||||
|
group.querySelectorAll(".setting-row").forEach((node) => {
|
||||||
|
const row = rows.find((item) => item.element === node);
|
||||||
|
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
|
||||||
|
const searchMatch = rowMatchesSearch(row);
|
||||||
|
const changedMatch = !state.showChangedOnly || isDirty(row);
|
||||||
|
const lockedMatch = !state.showLockedOnly || row.locked;
|
||||||
|
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
|
||||||
|
node.classList.toggle("is-hidden", !show);
|
||||||
|
if (show) {
|
||||||
|
visible += 1;
|
||||||
|
groupVisible += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.hidden = groupVisible === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleCount.textContent = String(visible);
|
||||||
|
statusMiddle.textContent = `category: ${state.currentCategory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
let dirty = 0;
|
||||||
|
let editable = 0;
|
||||||
|
let locked = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row.locked) locked += 1;
|
||||||
|
else editable += 1;
|
||||||
|
if (isDirty(row)) dirty += 1;
|
||||||
|
if (!validateRow(row)) invalid += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
editableCount.textContent = String(editable);
|
||||||
|
lockedCount.textContent = String(locked);
|
||||||
|
unsavedCount.textContent = String(dirty);
|
||||||
|
dirtyChip.textContent = `${dirty} unsaved`;
|
||||||
|
dirtyChip.classList.toggle("is-dirty", dirty > 0);
|
||||||
|
saveButton.disabled = dirty === 0 || invalid > 0;
|
||||||
|
|
||||||
|
if (invalid > 0) {
|
||||||
|
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
|
||||||
|
statusLeft.textContent = "Invalid values";
|
||||||
|
statusRight.textContent = "fix before save";
|
||||||
|
} else if (dirty > 0) {
|
||||||
|
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
|
||||||
|
statusLeft.textContent = "Unsaved changes";
|
||||||
|
statusRight.textContent = "draft ready";
|
||||||
|
} else {
|
||||||
|
actionSummary.textContent = "No unsaved changes.";
|
||||||
|
statusLeft.textContent = "No unsaved changes";
|
||||||
|
statusRight.textContent = "admin only";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
updateStats();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategory(category) {
|
||||||
|
state.currentCategory = category;
|
||||||
|
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftValues() {
|
||||||
|
const values = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row.locked && isDirty(row)) values[row.key] = currentValue(row);
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRowFromPayload(payload) {
|
||||||
|
const row = rows.find((item) => item.key === payload.key);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
row.element.dataset.original = payload.value;
|
||||||
|
row.element.dataset.default = payload.default_value || "";
|
||||||
|
row.element.dataset.source = payload.source || "default";
|
||||||
|
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
|
||||||
|
row.element.dataset.description = payload.description || "";
|
||||||
|
row.element.dataset.minimum = String(payload.minimum || 0);
|
||||||
|
row.element.classList.toggle("is-locked", Boolean(payload.locked));
|
||||||
|
row.locked = Boolean(payload.locked);
|
||||||
|
row.minimum = Number(payload.minimum || 0);
|
||||||
|
|
||||||
|
if (row.input) {
|
||||||
|
row.input.value = payload.value ?? "";
|
||||||
|
row.input.disabled = Boolean(payload.locked);
|
||||||
|
}
|
||||||
|
if (row.hint) {
|
||||||
|
row.hint.textContent = payload.locked
|
||||||
|
? "Locked by environment or hard runtime implication."
|
||||||
|
: payload.type === "size_gb"
|
||||||
|
? "Use GB values. Decimals allowed, for example `0.5`."
|
||||||
|
: (payload.default_value ? `Default: ${payload.default_value}` : "");
|
||||||
|
}
|
||||||
|
if (row.badge) {
|
||||||
|
row.badge.textContent = payload.source_badge || payload.source || "default";
|
||||||
|
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
|
||||||
|
}
|
||||||
|
rowData[payload.key] = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(source) {
|
||||||
|
if (source === "default") return "badge-default";
|
||||||
|
if (source === "environment") return "badge-env";
|
||||||
|
if (source === "db override") return "badge-db";
|
||||||
|
return "badge-hard";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateRows(payloadRows) {
|
||||||
|
if (!Array.isArray(payloadRows)) return;
|
||||||
|
payloadRows.forEach(updateRowFromPayload);
|
||||||
|
updateView();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, body) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "Request failed");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
const values = draftValues();
|
||||||
|
if (Object.keys(values).length === 0) {
|
||||||
|
showToast("No changed settings to save", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/save", { values });
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetDefaults() {
|
||||||
|
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/reset", {});
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(payload.message || "Defaults restored", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSingleSetting(row) {
|
||||||
|
if (row.locked || !row.input) return;
|
||||||
|
|
||||||
|
if (isDirty(row)) {
|
||||||
|
row.input.value = row.element.dataset.original || "";
|
||||||
|
updateView();
|
||||||
|
showToast(`${row.label} draft cleared`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await postJSON("/admin/settings/reset", { keys: [row.key] });
|
||||||
|
hydrateRows(payload.rows);
|
||||||
|
showToast(`${row.label} reset`, "success");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/settings/export");
|
||||||
|
if (!response.ok) throw new Error("Could not export settings");
|
||||||
|
const payload = await response.json();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast("Settings JSON exported");
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, "error", 3200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSettingsFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const payload = JSON.parse(text);
|
||||||
|
const result = await postJSON("/admin/settings/import", payload);
|
||||||
|
hydrateRows(result.rows);
|
||||||
|
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message || "Could not import settings JSON", "error", 3200);
|
||||||
|
} finally {
|
||||||
|
importInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnsaved() {
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row.input) return;
|
||||||
|
row.input.value = row.element.dataset.original || "";
|
||||||
|
});
|
||||||
|
updateView();
|
||||||
|
showToast("Unsaved changes discarded");
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainSources() {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
"Setting Sources",
|
||||||
|
`
|
||||||
|
<ul>
|
||||||
|
<li><strong>default</strong>: built-in application value.</li>
|
||||||
|
<li><strong>environment</strong>: loaded from an environment variable.</li>
|
||||||
|
<li><strong>db override</strong>: saved from the admin settings page.</li>
|
||||||
|
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function explainReset() {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
"Reset Behavior",
|
||||||
|
`
|
||||||
|
<p>Reset clears saved admin overrides.</p>
|
||||||
|
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</p>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRowInfo(row) {
|
||||||
|
window.WarpBoxUI?.openPopup?.(
|
||||||
|
row.label,
|
||||||
|
`
|
||||||
|
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
|
||||||
|
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
|
||||||
|
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
|
||||||
|
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "save":
|
||||||
|
await saveChanges();
|
||||||
|
return;
|
||||||
|
case "export":
|
||||||
|
await exportSettings();
|
||||||
|
return;
|
||||||
|
case "import":
|
||||||
|
importInput.click();
|
||||||
|
return;
|
||||||
|
case "discard":
|
||||||
|
discardUnsaved();
|
||||||
|
return;
|
||||||
|
case "show-all":
|
||||||
|
state.showChangedOnly = false;
|
||||||
|
state.showLockedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast("Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "show-changed":
|
||||||
|
state.showChangedOnly = !state.showChangedOnly;
|
||||||
|
if (state.showChangedOnly) state.showLockedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "show-locked":
|
||||||
|
state.showLockedOnly = !state.showLockedOnly;
|
||||||
|
if (state.showLockedOnly) state.showChangedOnly = false;
|
||||||
|
applyFilters();
|
||||||
|
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
|
||||||
|
return;
|
||||||
|
case "reset-defaults":
|
||||||
|
await resetDefaults();
|
||||||
|
return;
|
||||||
|
case "reload":
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
case "legend":
|
||||||
|
explainSources();
|
||||||
|
return;
|
||||||
|
case "reset-help":
|
||||||
|
explainReset();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
showToast(`Unknown command: ${command}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
|
||||||
|
row.element.querySelector(".row-reset")?.addEventListener("click", () => resetSingleSetting(row));
|
||||||
|
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", applyFilters);
|
||||||
|
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
|
||||||
|
saveButton.addEventListener("click", saveChanges);
|
||||||
|
exportButton.addEventListener("click", exportSettings);
|
||||||
|
importButton.addEventListener("click", () => importInput.click());
|
||||||
|
resetButton.addEventListener("click", resetDefaults);
|
||||||
|
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
|
||||||
|
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||||
|
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
menuController.close();
|
||||||
|
await runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", async (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
|
||||||
|
event.preventDefault();
|
||||||
|
await saveChanges();
|
||||||
|
}
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
menuController.close();
|
||||||
|
window.WarpBoxUI?.closePopup?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateView();
|
||||||
|
})();
|
||||||
304
static/js/admin/users.js
Normal file
304
static/js/admin/users.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
(() => {
|
||||||
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
|
const toastTarget = document.getElementById("toast");
|
||||||
|
const body = document.getElementById("users-body");
|
||||||
|
const search = document.getElementById("users-search");
|
||||||
|
const status = document.getElementById("users-status");
|
||||||
|
const role = document.getElementById("users-role-filter");
|
||||||
|
const sort = document.getElementById("users-sort");
|
||||||
|
const size = document.getElementById("users-size");
|
||||||
|
const masterCheck = document.getElementById("users-master-check");
|
||||||
|
const pageInfo = document.getElementById("users-page-info");
|
||||||
|
const visiblePill = document.getElementById("visible-pill");
|
||||||
|
const selectedPill = document.getElementById("users-selected-pill");
|
||||||
|
const prevBtn = document.getElementById("users-prev");
|
||||||
|
const nextBtn = document.getElementById("users-next");
|
||||||
|
const selectVisible = document.getElementById("select-visible");
|
||||||
|
const form = document.getElementById("users-form");
|
||||||
|
const modeInput = document.getElementById("users-mode");
|
||||||
|
const usernameInput = document.getElementById("users-username");
|
||||||
|
const emailInput = document.getElementById("users-email");
|
||||||
|
const roleInput = document.getElementById("users-role");
|
||||||
|
const planInput = document.getElementById("users-plan");
|
||||||
|
const statusLeft = document.getElementById("users-status-left");
|
||||||
|
|
||||||
|
if (!body || !search || !status || !role || !sort || !size) return;
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
|
||||||
|
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
|
||||||
|
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
|
||||||
|
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
|
||||||
|
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
|
||||||
|
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
|
||||||
|
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
|
||||||
|
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
|
||||||
|
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
|
||||||
|
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
|
||||||
|
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
|
||||||
|
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
|
||||||
|
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
|
||||||
|
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = { page: 1, selected: new Set() };
|
||||||
|
|
||||||
|
function toast(message, type = "info") {
|
||||||
|
if (window.WarpBoxUI) {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!toastTarget) return;
|
||||||
|
toastTarget.textContent = message;
|
||||||
|
toastTarget.classList.add("is-visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered() {
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
const statusFilter = status.value;
|
||||||
|
const roleFilter = role.value;
|
||||||
|
const sortBy = sort.value;
|
||||||
|
const rows = users.filter((user) => {
|
||||||
|
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
||||||
|
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
|
||||||
|
const matchesRole = roleFilter === "all" || user.role === roleFilter;
|
||||||
|
return matchesQuery && matchesStatus && matchesRole;
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
|
||||||
|
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
|
||||||
|
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
|
||||||
|
return a.username.localeCompare(b.username);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paged(rows) {
|
||||||
|
const perPage = Number(size.value || 12);
|
||||||
|
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
||||||
|
if (state.page > pages) state.page = pages;
|
||||||
|
if (state.page < 1) state.page = 1;
|
||||||
|
const start = (state.page - 1) * perPage;
|
||||||
|
return { rows: rows.slice(start, start + perPage), pages, start };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusPill(value) {
|
||||||
|
return `<span class="users-pill ${value}">${value}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(user) {
|
||||||
|
const checked = state.selected.has(user.id) ? " checked" : "";
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><input type="checkbox" class="row-check"${checked}></td>
|
||||||
|
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
|
||||||
|
<td title="${user.email}">${user.email}</td>
|
||||||
|
<td>${statusPill(user.status)}</td>
|
||||||
|
<td>${user.role}</td>
|
||||||
|
<td>${user.plan}</td>
|
||||||
|
<td>${user.boxes}</td>
|
||||||
|
<td>${user.lastSeen}</td>
|
||||||
|
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||||
|
if (event.target.checked) state.selected.add(user.id);
|
||||||
|
else state.selected.delete(user.id);
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
});
|
||||||
|
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
|
||||||
|
toast(`Mock user preview: ${user.username}`);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSelected() {
|
||||||
|
selectedPill.textContent = `${state.selected.size} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMasterCheck() {
|
||||||
|
const checks = Array.from(body.querySelectorAll(".row-check"));
|
||||||
|
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
document.getElementById("stat-total").textContent = String(users.length);
|
||||||
|
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
|
||||||
|
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
|
||||||
|
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const rows = filtered();
|
||||||
|
const page = paged(rows);
|
||||||
|
body.innerHTML = "";
|
||||||
|
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
||||||
|
|
||||||
|
visiblePill.textContent = `${rows.length} visible`;
|
||||||
|
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
||||||
|
prevBtn.disabled = state.page <= 1;
|
||||||
|
nextBtn.disabled = state.page >= page.pages;
|
||||||
|
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
search.value = "";
|
||||||
|
status.value = "all";
|
||||||
|
role.value = "all";
|
||||||
|
sort.value = "username";
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBulk(nextStatus) {
|
||||||
|
const selected = users.filter((user) => state.selected.has(user.id));
|
||||||
|
if (!selected.length) {
|
||||||
|
toast("Select one or more users first", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected.forEach((user) => { user.status = nextStatus; });
|
||||||
|
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command) {
|
||||||
|
switch (command) {
|
||||||
|
case "invite":
|
||||||
|
modeInput.value = "invite";
|
||||||
|
toast("Invite mode selected");
|
||||||
|
break;
|
||||||
|
case "create":
|
||||||
|
modeInput.value = "create";
|
||||||
|
toast("Create mode selected");
|
||||||
|
break;
|
||||||
|
case "export":
|
||||||
|
toast("Mock CSV export complete");
|
||||||
|
break;
|
||||||
|
case "bulk-disable":
|
||||||
|
applyBulk("disabled");
|
||||||
|
break;
|
||||||
|
case "bulk-enable":
|
||||||
|
applyBulk("active");
|
||||||
|
break;
|
||||||
|
case "bulk-revoke":
|
||||||
|
toast("Mock session revocation queued");
|
||||||
|
break;
|
||||||
|
case "refresh":
|
||||||
|
toast("Users list refreshed");
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case "pending-only":
|
||||||
|
status.value = "pending";
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case "clear-filters":
|
||||||
|
clearFilters();
|
||||||
|
break;
|
||||||
|
case "policy-help":
|
||||||
|
toast("Policy editor will be added in user details later.");
|
||||||
|
break;
|
||||||
|
case "mock-note":
|
||||||
|
toast("Mock-only page: no backend writes yet.");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast(`Mock action: ${command}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[search, status, role, sort, size].forEach((el) => {
|
||||||
|
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
|
||||||
|
state.page = 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
prevBtn.addEventListener("click", () => {
|
||||||
|
state.page -= 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextBtn.addEventListener("click", () => {
|
||||||
|
state.page += 1;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
masterCheck.addEventListener("change", () => {
|
||||||
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const checkbox = row.querySelector(".row-check");
|
||||||
|
if (!checkbox) return;
|
||||||
|
checkbox.checked = masterCheck.checked;
|
||||||
|
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||||
|
if (masterCheck.checked) state.selected.add(userID);
|
||||||
|
else state.selected.delete(userID);
|
||||||
|
});
|
||||||
|
syncSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
selectVisible.addEventListener("click", () => {
|
||||||
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const checkbox = row.querySelector(".row-check");
|
||||||
|
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||||
|
if (!checkbox) return;
|
||||||
|
checkbox.checked = true;
|
||||||
|
state.selected.add(userID);
|
||||||
|
});
|
||||||
|
syncSelected();
|
||||||
|
syncMasterCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const username = usernameInput.value.trim();
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
const mode = modeInput.value;
|
||||||
|
if (!username || !email) {
|
||||||
|
toast("Username and email are required", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
users.unshift({
|
||||||
|
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
status: mode === "invite" ? "pending" : "active",
|
||||||
|
role: roleInput.value,
|
||||||
|
plan: planInput.value,
|
||||||
|
boxes: 0,
|
||||||
|
created: new Date().toISOString().slice(0, 10),
|
||||||
|
lastSeen: "never"
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
modeInput.value = "invite";
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
menuController.close();
|
||||||
|
runCommand(button.dataset.command);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") menuController.close();
|
||||||
|
if (event.key === "F5") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("refresh");
|
||||||
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
|
||||||
|
event.preventDefault();
|
||||||
|
runCommand("invite");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderStats();
|
||||||
|
render();
|
||||||
|
})();
|
||||||
421
static/js/app.js
421
static/js/app.js
@@ -1,416 +1,5 @@
|
|||||||
const fileInput = document.querySelector("#file-upload");
|
loadSettings();
|
||||||
const fileCount = document.querySelector("#upload-file-count");
|
updateLimitHint();
|
||||||
const fileList = document.querySelector(".upload-file-list");
|
syncMenuChecks();
|
||||||
const dropzone = document.querySelector(".upload-dropzone");
|
renderFiles();
|
||||||
const uploadForm = document.querySelector(".upload-form");
|
updateTerminal();
|
||||||
const uploadStatus = document.querySelector(".upload-statusbar span:first-child");
|
|
||||||
const boxStatus = document.querySelector(".upload-statusbar span:last-child");
|
|
||||||
const uploadResult = document.querySelector(".upload-result");
|
|
||||||
const boxLink = document.querySelector("#upload-box-link");
|
|
||||||
const shareButton = document.querySelector("#upload-share-button");
|
|
||||||
const overallProgressBar = document.querySelector(".upload-overall-bar");
|
|
||||||
const overallProgressPercent = document.querySelector(".upload-overall-percent");
|
|
||||||
|
|
||||||
let selectedFiles = [];
|
|
||||||
let statusTimer = null;
|
|
||||||
let shareURL = "";
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
let size = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (unitIndex === 0) {
|
|
||||||
return `${size} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(message) {
|
|
||||||
if (uploadStatus) {
|
|
||||||
uploadStatus.textContent = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopStatusAnimation() {
|
|
||||||
if (statusTimer) {
|
|
||||||
clearInterval(statusTimer);
|
|
||||||
statusTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateUploadStatus(getPrefix) {
|
|
||||||
let dotCount = 0;
|
|
||||||
stopStatusAnimation();
|
|
||||||
|
|
||||||
statusTimer = setInterval(() => {
|
|
||||||
dotCount = (dotCount % 3) + 1;
|
|
||||||
updateStatus(`${getPrefix()} Uploading${".".repeat(dotCount)}`);
|
|
||||||
}, 350);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBoxStatus(message) {
|
|
||||||
if (boxStatus) {
|
|
||||||
boxStatus.textContent = message;
|
|
||||||
boxStatus.title = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBoxLink(path) {
|
|
||||||
shareURL = path ? new URL(path, window.location.origin).toString() : "";
|
|
||||||
|
|
||||||
if (uploadResult) {
|
|
||||||
uploadResult.classList.toggle("is-hidden", !shareURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boxLink) {
|
|
||||||
boxLink.href = shareURL || "#";
|
|
||||||
boxLink.textContent = shareURL || "Waiting for upload";
|
|
||||||
boxLink.title = shareURL;
|
|
||||||
boxLink.classList.toggle("is-empty", !shareURL);
|
|
||||||
boxLink.setAttribute("aria-disabled", shareURL ? "false" : "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shareButton) {
|
|
||||||
shareButton.disabled = !shareURL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOverallProgress(percent) {
|
|
||||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
|
||||||
const displayPercent = `${Math.round(clampedPercent)}%`;
|
|
||||||
|
|
||||||
if (overallProgressBar) {
|
|
||||||
overallProgressBar.style.width = displayPercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overallProgressPercent) {
|
|
||||||
overallProgressPercent.textContent = displayPercent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverallProgress() {
|
|
||||||
const totalBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.file.size, 0);
|
|
||||||
const loadedBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.loaded, 0);
|
|
||||||
const uploadedCount = selectedFiles.filter((selectedFile) => selectedFile.uploaded).length;
|
|
||||||
const percent = totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0;
|
|
||||||
setOverallProgress(percent >= 100 && uploadedCount < selectedFiles.length ? 99 : percent);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFileCount() {
|
|
||||||
if (fileCount) {
|
|
||||||
fileCount.textContent = `${selectedFiles.length} ${selectedFiles.length === 1 ? "file" : "files"}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRowProgress(row, percent) {
|
|
||||||
const progressBar = row.querySelector(".upload-progress-bar");
|
|
||||||
if (progressBar) {
|
|
||||||
progressBar.style.width = `${Math.max(0, Math.min(100, percent))}%`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFileRow(selectedFile) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "upload-file-row";
|
|
||||||
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "upload-file-icon";
|
|
||||||
icon.setAttribute("aria-hidden", "true");
|
|
||||||
|
|
||||||
const name = document.createElement("span");
|
|
||||||
name.className = "upload-file-name";
|
|
||||||
name.textContent = selectedFile.file.name;
|
|
||||||
name.title = selectedFile.file.name;
|
|
||||||
|
|
||||||
const size = document.createElement("span");
|
|
||||||
size.className = "upload-file-size";
|
|
||||||
size.textContent = formatBytes(selectedFile.file.size);
|
|
||||||
|
|
||||||
const progress = document.createElement("span");
|
|
||||||
progress.className = "upload-progress";
|
|
||||||
progress.setAttribute("aria-hidden", "true");
|
|
||||||
|
|
||||||
const progressBar = document.createElement("span");
|
|
||||||
progressBar.className = "upload-progress-bar";
|
|
||||||
progress.append(progressBar);
|
|
||||||
|
|
||||||
row.append(icon, name, size, progress);
|
|
||||||
selectedFile.row = row;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedFiles(files) {
|
|
||||||
selectedFiles = Array.from(files || []).map((file) => ({
|
|
||||||
file,
|
|
||||||
loaded: 0,
|
|
||||||
row: null,
|
|
||||||
uploaded: false,
|
|
||||||
failed: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
updateFileCount();
|
|
||||||
|
|
||||||
if (!fileList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.replaceChildren();
|
|
||||||
|
|
||||||
if (!selectedFiles.length) {
|
|
||||||
const emptyState = document.createElement("p");
|
|
||||||
emptyState.className = "upload-empty-state";
|
|
||||||
emptyState.textContent = "No files selected";
|
|
||||||
fileList.append(emptyState);
|
|
||||||
updateStatus("Ready");
|
|
||||||
setBoxStatus("WarpBox");
|
|
||||||
setBoxLink("");
|
|
||||||
setOverallProgress(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
selectedFiles.forEach((selectedFile) => {
|
|
||||||
fragment.append(createFileRow(selectedFile));
|
|
||||||
});
|
|
||||||
|
|
||||||
fileList.append(fragment);
|
|
||||||
updateStatus("Files selected");
|
|
||||||
setBoxStatus("WarpBox");
|
|
||||||
setBoxLink("");
|
|
||||||
setOverallProgress(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createBox() {
|
|
||||||
const response = await fetch("/box", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
files: selectedFiles.map((selectedFile) => ({
|
|
||||||
name: selectedFile.file.name,
|
|
||||||
size: selectedFile.file.size,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Could not create upload box");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markFileStatus(selectedFile, status) {
|
|
||||||
if (!selectedFile.boxFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetch(`/box/${selectedFile.boxID}/files/${selectedFile.boxFile.id}/status`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadFile(boxID, selectedFile, onComplete) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", selectedFile.file);
|
|
||||||
|
|
||||||
xhr.open("POST", selectedFile.boxFile.upload_path);
|
|
||||||
|
|
||||||
xhr.upload.addEventListener("loadstart", () => {
|
|
||||||
selectedFile.loaded = 0;
|
|
||||||
selectedFile.row.classList.add("is-uploading");
|
|
||||||
selectedFile.row.title = "Loading";
|
|
||||||
updateOverallProgress();
|
|
||||||
setRowProgress(selectedFile.row, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.upload.addEventListener("progress", (event) => {
|
|
||||||
if (!event.lengthComputable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFile.loaded = Math.min(event.loaded, selectedFile.file.size);
|
|
||||||
updateOverallProgress();
|
|
||||||
const percent = (event.loaded / event.total) * 100;
|
|
||||||
if (percent >= 100) {
|
|
||||||
selectedFile.row.classList.add("is-processing");
|
|
||||||
selectedFile.row.title = "Loading";
|
|
||||||
setRowProgress(selectedFile.row, 99);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRowProgress(selectedFile.row, percent);
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener("load", () => {
|
|
||||||
if (xhr.status < 200 || xhr.status >= 300) {
|
|
||||||
selectedFile.failed = true;
|
|
||||||
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
||||||
selectedFile.row.classList.add("is-failed");
|
|
||||||
selectedFile.row.title = "Failed to upload";
|
|
||||||
markFileStatus(selectedFile, "failed");
|
|
||||||
reject(new Error("Upload failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFile.uploaded = true;
|
|
||||||
selectedFile.loaded = selectedFile.file.size;
|
|
||||||
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
||||||
selectedFile.row.classList.add("is-uploaded");
|
|
||||||
selectedFile.row.title = "Uploaded";
|
|
||||||
updateOverallProgress();
|
|
||||||
setRowProgress(selectedFile.row, 100);
|
|
||||||
onComplete();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener("error", () => {
|
|
||||||
selectedFile.failed = true;
|
|
||||||
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
||||||
selectedFile.row.classList.add("is-failed");
|
|
||||||
selectedFile.row.title = "Failed to upload";
|
|
||||||
markFileStatus(selectedFile, "failed");
|
|
||||||
reject(new Error("Upload failed"));
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener("abort", () => {
|
|
||||||
selectedFile.failed = true;
|
|
||||||
selectedFile.row.classList.remove("is-uploading", "is-processing");
|
|
||||||
selectedFile.row.classList.add("is-failed");
|
|
||||||
selectedFile.row.title = "Failed to upload";
|
|
||||||
markFileStatus(selectedFile, "failed");
|
|
||||||
reject(new Error("Upload cancelled"));
|
|
||||||
});
|
|
||||||
|
|
||||||
markFileStatus(selectedFile, "uploading");
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.addEventListener("change", () => {
|
|
||||||
stopStatusAnimation();
|
|
||||||
updateSelectedFiles(fileInput.files);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput && dropzone) {
|
|
||||||
dropzone.addEventListener("dragover", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
dropzone.classList.add("is-dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener("dragleave", () => {
|
|
||||||
dropzone.classList.remove("is-dragging");
|
|
||||||
});
|
|
||||||
|
|
||||||
dropzone.addEventListener("drop", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
dropzone.classList.remove("is-dragging");
|
|
||||||
|
|
||||||
if (!event.dataTransfer.files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.files = event.dataTransfer.files;
|
|
||||||
stopStatusAnimation();
|
|
||||||
updateSelectedFiles(fileInput.files);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadForm) {
|
|
||||||
uploadForm.addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!selectedFiles.length) {
|
|
||||||
updateStatus("Choose files first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let completedCount = 0;
|
|
||||||
const totalCount = selectedFiles.length;
|
|
||||||
const statusPrefix = () => `${completedCount}/${totalCount}`;
|
|
||||||
|
|
||||||
selectedFiles.forEach((selectedFile) => {
|
|
||||||
selectedFile.uploaded = false;
|
|
||||||
selectedFile.failed = false;
|
|
||||||
selectedFile.loaded = 0;
|
|
||||||
selectedFile.row.classList.remove("is-uploaded", "is-failed", "is-uploading", "is-processing");
|
|
||||||
selectedFile.row.title = "";
|
|
||||||
setRowProgress(selectedFile.row, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
setBoxLink("");
|
|
||||||
setOverallProgress(0);
|
|
||||||
updateStatus(`${statusPrefix()} Uploading.`);
|
|
||||||
animateUploadStatus(statusPrefix);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const box = await createBox();
|
|
||||||
setBoxStatus(box.box_url);
|
|
||||||
setBoxLink(box.box_url);
|
|
||||||
|
|
||||||
selectedFiles.forEach((selectedFile, index) => {
|
|
||||||
selectedFile.boxID = box.box_id;
|
|
||||||
selectedFile.boxFile = box.files[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.allSettled(selectedFiles.map((selectedFile) => {
|
|
||||||
return uploadFile(box.box_id, selectedFile, () => {
|
|
||||||
completedCount += 1;
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
stopStatusAnimation();
|
|
||||||
const failedCount = selectedFiles.filter((selectedFile) => selectedFile.failed).length;
|
|
||||||
if (failedCount > 0) {
|
|
||||||
updateStatus(`${completedCount}/${totalCount} Uploaded, ${failedCount} failed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOverallProgress(100);
|
|
||||||
updateStatus(`${completedCount}/${totalCount} Uploaded`);
|
|
||||||
} catch (error) {
|
|
||||||
stopStatusAnimation();
|
|
||||||
setBoxLink("");
|
|
||||||
updateStatus("Upload failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shareButton) {
|
|
||||||
shareButton.addEventListener("click", async () => {
|
|
||||||
if (!shareURL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (navigator.share) {
|
|
||||||
await navigator.share({
|
|
||||||
title: "WarpBox download",
|
|
||||||
text: "Download these files from WarpBox",
|
|
||||||
url: shareURL,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(shareURL);
|
|
||||||
updateStatus("Link copied");
|
|
||||||
} catch (error) {
|
|
||||||
updateStatus("Share cancelled");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
356
static/js/box.js
356
static/js/box.js
@@ -1,31 +1,195 @@
|
|||||||
const boxPanel = document.querySelector(".box-panel[data-box-id]");
|
const boxPanel = document.querySelector(".box-panel[data-box-id]");
|
||||||
const boxStatus = document.querySelector(".box-statusbar span:first-child");
|
const boxStatus = document.querySelector(".box-statusbar span:first-child");
|
||||||
|
const boxAddress = document.querySelector("#box-address");
|
||||||
|
const boxExpiryMeta = document.querySelector(".box-meta[data-expires-at]");
|
||||||
|
const boxExpiryText = document.querySelector("#box-expiry-text");
|
||||||
|
const contextMenu = document.querySelector("#box-context-menu");
|
||||||
|
const docPopup = document.querySelector("#doc-popup");
|
||||||
|
const docPopupTitle = document.querySelector("#doc-popup-title");
|
||||||
|
const docPopupBody = document.querySelector("#doc-popup-body");
|
||||||
|
const docPopupClose = document.querySelector("#doc-popup-close");
|
||||||
|
const modalBackdrop = document.querySelector("#modal-backdrop");
|
||||||
|
const toast = document.querySelector("#toast");
|
||||||
|
const zipOnly = boxPanel && boxPanel.dataset.zipOnly === "true";
|
||||||
|
|
||||||
document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => {
|
let contextFile = null;
|
||||||
item.addEventListener("click", (event) => {
|
let lastStatusSignature = "";
|
||||||
if (item.getAttribute("aria-disabled") === "true") {
|
|
||||||
event.preventDefault();
|
const htmlEscape = window.WarpBoxUI.htmlEscape;
|
||||||
|
|
||||||
|
function showToast(message, type = "info") {
|
||||||
|
window.WarpBoxUI.toast(message, type, { target: toast });
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateBoxFile(file) {
|
function openPopup(title, html, options = {}) {
|
||||||
const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`);
|
window.WarpBoxUI.openPopup(title, html, {
|
||||||
if (!item) {
|
...options,
|
||||||
|
popup: docPopup,
|
||||||
|
title: docPopupTitle,
|
||||||
|
body: docPopupBody,
|
||||||
|
backdrop: modalBackdrop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
window.WarpBoxUI.closePopup({ popup: docPopup, backdrop: modalBackdrop });
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentExpiryDate() {
|
||||||
|
const value = boxExpiryMeta?.dataset.expiresAt || "";
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms <= 0) return "expired";
|
||||||
|
const totalSeconds = Math.ceil(ms / 1000);
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (days) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours) return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
if (minutes) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpiryCountdown() {
|
||||||
|
if (!boxExpiryText || !boxExpiryMeta) return;
|
||||||
|
const expiry = currentExpiryDate();
|
||||||
|
if (!expiry) {
|
||||||
|
boxExpiryText.textContent = "Expires after one-time download";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boxExpiryText.textContent = `Expires in ${formatDuration(expiry.getTime() - Date.now())}`;
|
||||||
|
boxExpiryText.title = `Expires at ${expiry.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenu?.classList.remove("is-visible");
|
||||||
|
contextMenu?.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContextMenu(file, x, y) {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
contextFile = file;
|
||||||
|
contextMenu.style.left = `${Math.min(x, window.innerWidth - 190)}px`;
|
||||||
|
contextMenu.style.top = `${Math.min(y, window.innerHeight - 98)}px`;
|
||||||
|
contextMenu.classList.add("is-visible");
|
||||||
|
contextMenu.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileData(item) {
|
||||||
|
return {
|
||||||
|
id: item.dataset.fileId || "",
|
||||||
|
name: item.dataset.name || item.querySelector(".box-file-name")?.textContent || "",
|
||||||
|
size: item.dataset.size || "",
|
||||||
|
mime: item.dataset.mime || "",
|
||||||
|
status: item.dataset.status || "",
|
||||||
|
statusLabel: item.querySelector(".box-file-meta")?.textContent || "",
|
||||||
|
downloadPath: item.dataset.downloadPath || item.getAttribute("href") || "",
|
||||||
|
thumbnail: item.dataset.thumbnail || "",
|
||||||
|
canDownload: item.getAttribute("aria-disabled") !== "true" && item.getAttribute("href") !== "#",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(item) {
|
||||||
|
const data = fileData(item);
|
||||||
|
if (!data.canDownload) {
|
||||||
|
showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready for download yet.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = data.downloadPath;
|
||||||
|
setTimeout(refreshBoxStatus, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewURL(data) {
|
||||||
|
return data.canDownload ? data.downloadPath : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewFile(item) {
|
||||||
|
const data = fileData(item);
|
||||||
|
if (zipOnly) {
|
||||||
|
showToast("Previews are disabled for one-time boxes. Use Download Zip.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = previewURL(data);
|
||||||
|
if (!url) {
|
||||||
|
showToast("This file is not ready to preview yet.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mime = data.mime.toLowerCase();
|
||||||
|
const name = htmlEscape(data.name);
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
openPopup(`${data.name} preview`, `<img class="preview-frame" src="${htmlEscape(url)}" alt="${name}">`, { preview: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("video/")) {
|
||||||
|
openPopup(`${data.name} preview`, `<video class="preview-frame" src="${htmlEscape(url)}" controls></video>`, { preview: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("audio/")) {
|
||||||
|
openPopup(`${data.name} preview`, `<audio class="preview-frame" src="${htmlEscape(url)}" controls></audio>`, { preview: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mime === "application/pdf") {
|
||||||
|
openPopup(`${data.name} preview`, `<iframe class="preview-frame" src="${htmlEscape(url)}" title="${name}"></iframe>`, { preview: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("text/") || /\.(txt|md|json|csv|log|html|css|js)$/i.test(data.name)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error("Preview failed");
|
||||||
|
const text = await response.text();
|
||||||
|
openPopup(`${data.name} preview`, `<pre class="code-block preview-frame is-text"><code>${htmlEscape(text.slice(0, 120000))}</code></pre>`, { preview: true });
|
||||||
|
} catch (_) {
|
||||||
|
showToast("The browser could not load a text preview.", "error");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast("This file type cannot be previewed in the browser.", "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProperties(item) {
|
||||||
|
const data = fileData(item);
|
||||||
|
const url = data.downloadPath ? new URL(data.downloadPath, window.location.origin).toString() : "Not ready";
|
||||||
|
openPopup(`${data.name} Properties`, `
|
||||||
|
<h3>${htmlEscape(data.name)}</h3>
|
||||||
|
<dl class="properties-grid">
|
||||||
|
<dt>Name</dt><dd>${htmlEscape(data.name)}</dd>
|
||||||
|
<dt>Size</dt><dd>${htmlEscape(data.size || "Unknown")}</dd>
|
||||||
|
<dt>Type</dt><dd>${htmlEscape(data.mime || "Unknown")}</dd>
|
||||||
|
<dt>Status</dt><dd>${htmlEscape(data.statusLabel || data.status || "Unknown")}</dd>
|
||||||
|
<dt>File ID</dt><dd>${htmlEscape(data.id)}</dd>
|
||||||
|
<dt>Location</dt><dd>${htmlEscape(url)}</dd>
|
||||||
|
</dl>
|
||||||
|
`, { properties: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBoxFile(file) {
|
||||||
|
const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
const meta = item.querySelector(".box-file-meta");
|
const meta = item.querySelector(".box-file-meta");
|
||||||
|
const icon = item.querySelector(".box-file-icon");
|
||||||
const isComplete = file.status === "complete";
|
const isComplete = file.status === "complete";
|
||||||
const isFailed = file.status === "failed";
|
const isFailed = file.status === "failed";
|
||||||
|
|
||||||
item.classList.toggle("is-complete", isComplete);
|
item.classList.toggle("is-complete", isComplete);
|
||||||
item.classList.toggle("is-failed", isFailed);
|
item.classList.toggle("is-failed", isFailed);
|
||||||
item.classList.toggle("is-loading", !isComplete && !isFailed);
|
item.classList.toggle("is-loading", !isComplete && !isFailed);
|
||||||
|
item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path));
|
||||||
item.dataset.status = file.status;
|
item.dataset.status = file.status;
|
||||||
|
item.dataset.name = file.name || item.dataset.name || "";
|
||||||
|
item.dataset.size = file.size_label || item.dataset.size || "";
|
||||||
|
item.dataset.mime = file.mime_type || item.dataset.mime || "";
|
||||||
|
item.dataset.downloadPath = file.download_path || item.dataset.downloadPath || "";
|
||||||
|
item.dataset.thumbnail = file.thumbnail_path || "";
|
||||||
item.title = file.title;
|
item.title = file.title;
|
||||||
|
|
||||||
if (isComplete) {
|
if (isComplete && !zipOnly) {
|
||||||
item.href = file.download_path;
|
item.href = file.download_path;
|
||||||
item.setAttribute("download", "");
|
item.setAttribute("download", "");
|
||||||
item.removeAttribute("aria-disabled");
|
item.removeAttribute("aria-disabled");
|
||||||
@@ -35,23 +199,26 @@ function updateBoxFile(file) {
|
|||||||
item.setAttribute("aria-disabled", "true");
|
item.setAttribute("aria-disabled", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta) {
|
if (meta) meta.textContent = `${file.status_label} · ${file.size_label}`;
|
||||||
meta.textContent = `${file.status_label} · ${file.size_label}`;
|
if (icon) icon.src = file.thumbnail_path || file.icon_path;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshBoxStatus() {
|
async function refreshBoxStatus() {
|
||||||
if (!boxPanel) {
|
if (!boxPanel) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxID = boxPanel.dataset.boxId;
|
const boxID = boxPanel.dataset.boxId;
|
||||||
const response = await fetch(`/box/${boxID}/status`);
|
const response = await fetch(`/box/${boxID}/status`);
|
||||||
if (!response.ok) {
|
if (!response.ok) return { changed: false, hasLoadingFiles: true };
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
const signature = statusSignature(result);
|
||||||
|
const changed = signature !== lastStatusSignature;
|
||||||
|
lastStatusSignature = signature;
|
||||||
|
|
||||||
|
if (boxExpiryMeta && typeof result.expires_at === "string") {
|
||||||
|
boxExpiryMeta.dataset.expiresAt = result.expires_at;
|
||||||
|
updateExpiryCountdown();
|
||||||
|
}
|
||||||
result.files.forEach(updateBoxFile);
|
result.files.forEach(updateBoxFile);
|
||||||
|
|
||||||
if (boxStatus) {
|
if (boxStatus) {
|
||||||
@@ -59,14 +226,151 @@ async function refreshBoxStatus() {
|
|||||||
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
|
boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.files.some((file) => file.status === "pending" || file.status === "uploading");
|
const hasLoadingFiles = result.files.some((file) => {
|
||||||
|
const isUploading = file.status === "pending" || file.status === "uploading";
|
||||||
|
const isWaitingForThumbnail = file.status === "complete" && !file.thumbnail_status && !file.thumbnail_path;
|
||||||
|
return isUploading || isWaitingForThumbnail || file.thumbnail_status === "processing";
|
||||||
|
});
|
||||||
|
|
||||||
|
return { changed, hasLoadingFiles };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusSignature(result) {
|
||||||
|
const files = Array.isArray(result.files) ? result.files : [];
|
||||||
|
return JSON.stringify({
|
||||||
|
expiresAt: result.expires_at || "",
|
||||||
|
files: files.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
status: file.status,
|
||||||
|
size: file.size,
|
||||||
|
thumbnailPath: file.thumbnail_path || "",
|
||||||
|
thumbnailStatus: file.thumbnail_status || "",
|
||||||
|
downloadPath: file.download_path || "",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollingStages(baseMS) {
|
||||||
|
return [
|
||||||
|
{ interval: baseMS, attempts: 10 },
|
||||||
|
{ interval: baseMS * 2, attempts: 20 },
|
||||||
|
{ interval: baseMS * 10, attempts: 100 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStagedPolling(baseMS) {
|
||||||
|
const stages = pollingStages(baseMS);
|
||||||
|
let stageIndex = 0;
|
||||||
|
let attemptsInStage = 0;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
const stage = stages[stageIndex];
|
||||||
|
try {
|
||||||
|
const result = await refreshBoxStatus();
|
||||||
|
if (result.changed) {
|
||||||
|
stageIndex = 0;
|
||||||
|
attemptsInStage = 0;
|
||||||
|
} else {
|
||||||
|
attemptsInStage += 1;
|
||||||
|
if (attemptsInStage >= stage.attempts) {
|
||||||
|
stageIndex += 1;
|
||||||
|
attemptsInStage = 0;
|
||||||
|
if (stageIndex >= stages.length) {
|
||||||
|
stopped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
attemptsInStage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) {
|
||||||
|
window.setTimeout(tick, stages[stageIndex].interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setTimeout(tick, stages[0].interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBoxAction(action) {
|
||||||
|
const actions = {
|
||||||
|
"fake-close": () => showToast("Close clicked. The download window is emotionally attached.", "warning"),
|
||||||
|
minimize: () => showToast("Minimize clicked. WarpBox refuses to disappear quietly."),
|
||||||
|
"toggle-fit": () => {
|
||||||
|
document.body.classList.toggle("fit-window");
|
||||||
|
showToast("Maximize clicked. The window is doing its best.");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[action]?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runContextAction(action, item) {
|
||||||
|
const actions = {
|
||||||
|
preview: () => previewFile(item),
|
||||||
|
download: () => downloadFile(item),
|
||||||
|
properties: () => showProperties(item),
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[action]?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const action = event.target.closest("[data-action]")?.dataset.action;
|
||||||
|
if (action) runBoxAction(action);
|
||||||
|
|
||||||
|
const contextAction = event.target.closest("[data-context-action]")?.dataset.contextAction;
|
||||||
|
if (contextAction && contextFile) {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = contextFile;
|
||||||
|
closeContextMenu();
|
||||||
|
runContextAction(contextAction, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest("#box-context-menu")) closeContextMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".box-file").forEach((item) => {
|
||||||
|
item.addEventListener("click", (event) => {
|
||||||
|
if (item.getAttribute("aria-disabled") === "true") {
|
||||||
|
event.preventDefault();
|
||||||
|
showToast(zipOnly ? "Individual file downloads are disabled for one-time boxes. Use Download Zip." : "This file is not ready yet.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(refreshBoxStatus, 900);
|
||||||
|
});
|
||||||
|
item.addEventListener("contextmenu", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
showContextMenu(item, event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
boxAddress?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
|
showToast("Current box URL copied.");
|
||||||
|
} catch (_) {
|
||||||
|
openPopup("Copy box URL", `<p>Clipboard access failed. Copy this URL manually.</p><textarea class="copy-fallback-text" readonly>${htmlEscape(window.location.href)}</textarea>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
docPopupClose?.addEventListener("click", closePopup);
|
||||||
|
modalBackdrop?.addEventListener("click", closePopup);
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closePopup();
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExpiryCountdown();
|
||||||
|
setInterval(updateExpiryCountdown, 1000);
|
||||||
|
|
||||||
if (boxPanel) {
|
if (boxPanel) {
|
||||||
const timer = setInterval(async () => {
|
const pollMS = Number.parseInt(boxPanel.dataset.pollMs, 10) || 5000;
|
||||||
const hasLoadingFiles = await refreshBoxStatus();
|
startStagedPolling(pollMS);
|
||||||
if (!hasLoadingFiles) {
|
|
||||||
clearInterval(timer);
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
static/js/upload-popups.js
Normal file
36
static/js/upload-popups.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
window.WBPopups = (() => {
|
||||||
|
const cache = new Map();
|
||||||
|
const docs = {
|
||||||
|
cli: { title: "CLI Guide", template: "cli" },
|
||||||
|
faq: { title: "Help & FAQ", template: "faq" },
|
||||||
|
dailyQuota: { title: "Upload limits", template: "upload-limits" },
|
||||||
|
about: { title: "About WarpBox", template: "about", about: true },
|
||||||
|
examples: { title: "Examples", template: "examples" },
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadTemplate(name) {
|
||||||
|
if (cache.has(name)) return cache.get(name);
|
||||||
|
const response = await fetch(`/static/popups/${name}.html`, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error(`Could not load popup template: ${name}`);
|
||||||
|
const template = await response.text();
|
||||||
|
cache.set(name, template);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTemplate(name, data = {}) {
|
||||||
|
const template = await loadTemplate(name);
|
||||||
|
return window.WBUtils.renderTemplate(template, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDoc(name, data = {}) {
|
||||||
|
const doc = docs[name];
|
||||||
|
if (!doc) return null;
|
||||||
|
return {
|
||||||
|
title: doc.title,
|
||||||
|
about: Boolean(doc.about),
|
||||||
|
html: await renderTemplate(doc.template, data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderTemplate, renderDoc };
|
||||||
|
})();
|
||||||
11
static/js/upload-utils.js
Normal file
11
static/js/upload-utils.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
window.WBUtils = (() => {
|
||||||
|
function renderTemplate(template, data = {}) {
|
||||||
|
return window.WarpBoxUI?.renderTemplate
|
||||||
|
? window.WarpBoxUI.renderTemplate(template, data)
|
||||||
|
: String(template).replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
|
||||||
|
return Object.prototype.hasOwnProperty.call(data, key) ? String(data[key]) : "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderTemplate };
|
||||||
|
})();
|
||||||
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();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user