Compare commits

...

20 Commits

Author SHA1 Message Date
5026134eef fix: clean all blossom server urls
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 15:54:38 +01:00
0538eaeba3 fix: build
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 15:20:35 +01:00
84be9c16bb feat: mirror tool
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-18 15:17:33 +01:00
84b475d11a fix: AI slop 2025-06-18 14:48:40 +01:00
9290443a20 Add mirror suggestions feature for blossom servers (#35)
Some checks failed
continuous-integration/drone/push Build is failing
* Initial plan for issue

* Fix build by adding feature gates for payments-related functions

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Add mirror suggestions feature with backend API and frontend UI

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove payments feature gate and implement mirror suggestions entirely in frontend

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Address review comments: revert blossom.rs changes and load server list from BUD-03

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Revert yarn.lock changes and update server-config to load from nostr event kind 10063

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove server-config.tsx and update useBlossomServers hook to use default servers

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
2025-06-18 14:04:04 +01:00
b2fb86021b UI updates: Remove NIP96, implement auto-upload, default compression, and simplified quota display (#32)
All checks were successful
continuous-integration/drone/push Build is passing
* Initial plan for issue

* Implement UI updates: Remove NIP96, auto-upload, default compression, updated quota display

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Remove unnecessary Blossom protocol comment from HTML interface

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
2025-06-17 22:20:04 +01:00
5bd47af70b Add upload progress bar and average speed while uploading (#30)
All checks were successful
continuous-integration/drone/push Build is passing
* Initial plan for issue

* Implement upload progress tracking with speed and progress bar

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
2025-06-17 20:54:42 +01:00
01c5281425 chore: restore r96util
All checks were successful
continuous-integration/drone/push Build is passing
closes #28
2025-06-17 16:15:49 +01:00
510ba00368 Fix MIME type filtering in admin file list (#27)
All checks were successful
continuous-integration/drone/push Build is passing
* Initial plan for issue

* Initial analysis and fix compilation issue

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

* Fix MIME type filtering SQL query logic

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
2025-06-17 15:25:22 +01:00
dccc2d23eb Fix page buttons not working in admin panel (#25)
All checks were successful
continuous-integration/drone/push Build is passing
* Initial plan for issue

* Fix pagination issue by removing blocking conditions in useEffect hooks

Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: v0l <1172179+v0l@users.noreply.github.com>
2025-06-17 13:50:38 +01:00
664499d22c fix: mime match starts with
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-17 13:15:56 +01:00
455970b9fe fix: mime like
Some checks reported errors
continuous-integration/drone Build was killed
continuous-integration/drone/push Build is passing
2025-06-17 11:56:00 +01:00
b6c12de685 fix: decoder bug
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-11 23:58:44 +01:00
470af79a24 fix: enable libx265
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-11 15:59:52 +01:00
5048c4104a fix: disable quota checks when not configured 2025-06-11 15:58:43 +01:00
6b6e0d4dec chore: try ffmpeg master branch
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-11 15:14:53 +01:00
2576f75bc4 chore: cache docker build 2025-06-11 14:48:46 +01:00
ec271f4109 fix: install protobuf-compiler
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-11 14:45:10 +01:00
21cc1ed714 fix: storage calculation
Some checks failed
continuous-integration/drone/push Build is failing
refactor: improve UI
2025-06-11 14:42:03 +01:00
dd6b35380b feat: new UI
chore: update readme
fix: upload
2025-06-11 12:52:04 +01:00
42 changed files with 2573 additions and 634 deletions

View File

@ -5,17 +5,24 @@ metadata:
namespace: git namespace: git
concurrency: concurrency:
limit: 1 limit: 1
volumes:
- name: cache
claim:
name: storage2
steps: steps:
- name: build - name: build
image: docker image: docker
privileged: true privileged: true
volumes:
- name: cache
path: /cache
environment: environment:
TOKEN: TOKEN:
from_secret: gitea from_secret: gitea
TOKEN_DOCKER: TOKEN_DOCKER:
from_secret: docker_hub from_secret: docker_hub
commands: commands:
- dockerd & - dockerd --data-root /cache/dockerd &
- docker login -u voidic -p $TOKEN_DOCKER - docker login -u voidic -p $TOKEN_DOCKER
- docker buildx build --push -t voidic/route96:latest . - docker buildx build --push -t voidic/route96:latest .
- kill $(cat /var/run/docker.pid) - kill $(cat /var/run/docker.pid)
@ -30,17 +37,24 @@ trigger:
- tag - tag
metadata: metadata:
namespace: git namespace: git
volumes:
- name: cache
claim:
name: storage2
steps: steps:
- name: build - name: build
image: docker image: docker
privileged: true privileged: true
volumes:
- name: cache
path: /cache
environment: environment:
TOKEN: TOKEN:
from_secret: gitea from_secret: gitea
TOKEN_DOCKER: TOKEN_DOCKER:
from_secret: docker_hub from_secret: docker_hub
commands: commands:
- dockerd & - dockerd --data-root /cache/dockerd &
- docker login -u voidic -p $TOKEN_DOCKER - docker login -u voidic -p $TOKEN_DOCKER
- docker buildx build --push voidic/route96:$DRONE_TAG . - docker buildx build --push voidic/route96:$DRONE_TAG .
- kill $(cat /var/run/docker.pid) - kill $(cat /var/run/docker.pid)

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
target/ target/
data/ data/
.idea/ .idea/
ui_src/dist/
ui_src/node_modules/
ui_src/package-lock.json
ui_src/*.tsbuildinfo

65
Cargo.lock generated
View File

@ -691,6 +691,19 @@ dependencies = [
"yaml-rust2", "yaml-rust2",
] ]
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -958,6 +971,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -1070,7 +1089,7 @@ dependencies = [
[[package]] [[package]]
name = "ffmpeg-rs-raw" name = "ffmpeg-rs-raw"
version = "0.1.0" version = "0.1.0"
source = "git+https://git.v0l.io/Kieran/ffmpeg-rs-raw.git?rev=928ab9664ff47c1b0bd8313ebc73d13b1ab43fc5#928ab9664ff47c1b0bd8313ebc73d13b1ab43fc5" source = "git+https://git.v0l.io/Kieran/ffmpeg-rs-raw.git?rev=aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7#aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ffmpeg-sys-the-third", "ffmpeg-sys-the-third",
@ -1975,6 +1994,19 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]] [[package]]
name = "infer" name = "infer"
version = "0.19.0" version = "0.19.0"
@ -2454,6 +2486,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@ -2760,6 +2798,12 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@ -3265,6 +3309,7 @@ dependencies = [
"ffmpeg-rs-raw", "ffmpeg-rs-raw",
"hex", "hex",
"http-range-header", "http-range-header",
"indicatif",
"infer", "infer",
"libc", "libc",
"log", "log",
@ -3278,7 +3323,9 @@ dependencies = [
"sqlx", "sqlx",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url",
"uuid", "uuid",
"walkdir",
] ]
[[package]] [[package]]
@ -4528,6 +4575,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -4742,6 +4795,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.0" version = "1.6.0"

View File

@ -11,7 +11,7 @@ path = "src/bin/main.rs"
name = "route96" name = "route96"
[features] [features]
default = ["nip96", "blossom", "analytics", "react-ui", "payments"] default = ["nip96", "blossom", "analytics", "react-ui", "payments", "r96util"]
media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"] media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"]
labels = ["media-compression", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"] labels = ["media-compression", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"]
nip96 = ["media-compression"] nip96 = ["media-compression"]
@ -19,6 +19,7 @@ blossom = []
analytics = [] analytics = []
react-ui = [] react-ui = []
payments = ["dep:fedimint-tonic-lnd"] payments = ["dep:fedimint-tonic-lnd"]
r96util = ["dep:walkdir", "dep:indicatif"]
[dependencies] [dependencies]
log = "0.4.21" log = "0.4.21"
@ -35,17 +36,20 @@ sha2 = "0.10.8"
sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] } sqlx = { version = "0.8.1", features = ["mysql", "runtime-tokio", "chrono", "uuid"] }
config = { version = "0.15.7", features = ["yaml"] } config = { version = "0.15.7", features = ["yaml"] }
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] }
reqwest = { version = "0.12.8", features = ["stream", "http2"] } reqwest = { version = "0.12.8", features = ["stream", "http2", "json"] }
clap = { version = "4.5.18", features = ["derive"] } clap = { version = "4.5.18", features = ["derive"] }
mime2ext = "0.1.53" mime2ext = "0.1.53"
infer = "0.19.0" infer = "0.19.0"
tokio-util = { version = "0.7.13", features = ["io", "io-util"] } tokio-util = { version = "0.7.13", features = ["io", "io-util"] }
http-range-header = { version = "0.4.2" } http-range-header = { version = "0.4.2" }
base58 = "0.2.0" base58 = "0.2.0"
url = "2.5.0"
libc = { version = "0.2.153", optional = true } libc = { version = "0.2.153", optional = true }
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "928ab9664ff47c1b0bd8313ebc73d13b1ab43fc5", optional = true } ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "aa1ce3edcad0fcd286d39b3e0c2fdc610c3988e7", optional = true }
candle-core = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true } candle-core = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
candle-nn = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true } candle-nn = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
candle-transformers = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true } candle-transformers = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
fedimint-tonic-lnd = { version = "0.2.0", optional = true, default-features = false, features = ["invoicesrpc", "lightningrpc"] } fedimint-tonic-lnd = { version = "0.2.0", optional = true, default-features = false, features = ["invoicesrpc", "lightningrpc"] }
walkdir = { version = "2.5.0", optional = true }
indicatif = { version = "0.17.11", optional = true }

View File

@ -12,10 +12,12 @@ RUN apt update && \
apt install -y \ apt install -y \
build-essential \ build-essential \
libx264-dev \ libx264-dev \
libx265-dev \
libwebp-dev \ libwebp-dev \
libvpx-dev \ libvpx-dev \
nasm \ nasm \
libclang-dev && \ libclang-dev \
protobuf-compiler && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
RUN git clone --single-branch --branch release/7.1 https://git.v0l.io/ffmpeg/FFmpeg.git && \ RUN git clone --single-branch --branch release/7.1 https://git.v0l.io/ffmpeg/FFmpeg.git && \
cd FFmpeg && \ cd FFmpeg && \
@ -26,6 +28,7 @@ RUN git clone --single-branch --branch release/7.1 https://git.v0l.io/ffmpeg/FFm
--disable-network \ --disable-network \
--enable-gpl \ --enable-gpl \
--enable-libx264 \ --enable-libx264 \
--enable-libx265 \
--enable-libwebp \ --enable-libwebp \
--enable-libvpx \ --enable-libvpx \
--disable-static \ --disable-static \

153
README.md
View File

@ -1,26 +1,143 @@
# route96 # Route96
Image hosting service Decentralized blob storage server with Nostr integration, supporting multiple protocols and advanced media processing capabilities.
## Features ## Core Features
- [NIP-96 Support](https://github.com/nostr-protocol/nips/blob/master/96.md) ### Protocol Support
- [Blossom Support](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - **[NIP-96](https://github.com/nostr-protocol/nips/blob/master/96.md)** - Nostr file storage with media processing
- [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - **[Blossom Protocol](https://github.com/hzrd149/blossom)** - Complete BUD specification compliance:
- [BUD-02](https://github.com/hzrd149/blossom/blob/master/buds/02.md) - [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - Blob retrieval (GET/HEAD)
- [BUD-04](https://github.com/hzrd149/blossom/blob/master/buds/04.md) - [BUD-02](https://github.com/hzrd149/blossom/blob/master/buds/02.md) - Upload, delete, list operations
- [BUD-05](https://github.com/hzrd149/blossom/blob/master/buds/05.md) - [BUD-04](https://github.com/hzrd149/blossom/blob/master/buds/04.md) - Blob mirroring from remote servers
- [BUD-06](https://github.com/hzrd149/blossom/blob/master/buds/06.md) - [BUD-05](https://github.com/hzrd149/blossom/blob/master/buds/05.md) - Media optimization endpoints
- [BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md) - [BUD-06](https://github.com/hzrd149/blossom/blob/master/buds/06.md) - Upload requirement validation
- Image compression to WebP - [BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md) - NIP-94 metadata support
- Blurhash calculation - [BUD-09](https://github.com/hzrd149/blossom/blob/master/buds/09.md) - Content reporting system
- AI image labeling ([ViT224](https://huggingface.co/google/vit-base-patch16-224))
- Plausible analytics
## Planned ### Media Processing
- **Image & Video Compression** - Automatic WebP conversion and optimization
- **Thumbnail Generation** - Auto-generated thumbnails for images and videos
- **Blurhash Calculation** - Progressive image loading with blur previews
- **AI Content Labeling** - Automated tagging using [ViT-224](https://huggingface.co/google/vit-base-patch16-224) model
- **Media Metadata** - Automatic extraction of dimensions, duration, bitrate
- **Range Request Support** - RFC 7233 compliant partial content delivery
- Torrent seed V2 ### Security & Administration
- Payment system - **Nostr Authentication** - Cryptographic identity with kind 24242 events
- **Whitelist Support** - Restrict uploads to approved public keys
- **Quota Management** - Per-user storage limits with payment integration
- **Content Reporting** - Community-driven moderation via NIP-56 reports
- **Admin Dashboard** - Web interface for content and user management
- **CORS Support** - Full cross-origin resource sharing compliance
### Payment System
- **Lightning Network** - Bitcoin payments via LND integration
- **Fiat Tracking** - Multi-currency support (USD/EUR/GBP/JPY/etc.)
- **Flexible Billing** - Usage-based pricing (storage, egress, time-based)
- **Free Quotas** - Configurable free tier for new users
### Analytics & Monitoring
- **Plausible Integration** - Privacy-focused usage analytics
- **Comprehensive Logging** - Detailed operation tracking
- **Health Monitoring** - Service status and performance metrics
## API Endpoints
### Blossom Protocol
- `GET /<sha256>` - Retrieve blob by hash
- `HEAD /<sha256>` - Check blob existence
- `PUT /upload` - Upload new blob
- `DELETE /<sha256>` - Delete owned blob
- `GET /list/<pubkey>` - List user's blobs
- `PUT /mirror` - Mirror blob from remote URL
- `PUT /media` - Upload with media optimization
- `HEAD /upload` - Validate upload requirements
- `PUT /report` - Submit content reports
### NIP-96 Protocol
- `GET /.well-known/nostr/nip96.json` - Server information
- `POST /nip96` - File upload with Nostr auth
- `DELETE /nip96/<sha256>` - Delete with Nostr auth
### Admin Interface
- `GET /admin/*` - Web dashboard for content management
- Admin API endpoints for reports and user management
## Configuration
Route96 uses YAML configuration. See [config.yaml](config.yaml) for a complete example:
```yaml
listen: "127.0.0.1:8000"
database: "mysql://user:pass@localhost:3306/route96"
storage_dir: "./data"
max_upload_bytes: 104857600 # 100MB
public_url: "https://your-domain.com"
# Optional: Restrict to specific pubkeys
whitelist: ["pubkey1", "pubkey2"]
# Optional: Payment system
payments:
free_quota_bytes: 104857600
cost:
currency: "BTC"
amount: 0.00000100
unit: "GBSpace"
interval:
month: 1
```
## Quick Start Examples
### Upload a file (Blossom)
```bash
# Create authorization event (kind 24242)
auth_event='{"kind":24242,"tags":[["t","upload"],["expiration","1234567890"]],"content":"Upload file"}'
auth_b64=$(echo $auth_event | base64 -w 0)
curl -X PUT http://localhost:8000/upload \
-H "Authorization: Nostr $auth_b64" \
-H "Content-Type: image/jpeg" \
--data-binary @image.jpg
```
### Retrieve a file
```bash
curl http://localhost:8000/abc123def456...789
```
### List user's files
```bash
curl http://localhost:8000/list/user_pubkey_hex
```
## Feature Flags
Route96 supports optional features that can be enabled at compile time:
- `nip96` (default) - NIP-96 protocol support
- `blossom` (default) - Blossom protocol support
- `media-compression` - WebP conversion and thumbnails
- `labels` - AI-powered content labeling
- `payments` (default) - Lightning payment integration
- `analytics` (default) - Plausible analytics
- `react-ui` (default) - Web dashboard interface
```bash
# Build with specific features
cargo build --features "blossom,payments,media-compression"
```
## Requirements
- **Rust** 1.70+
- **MySQL/MariaDB** - Database storage
- **FFmpeg libraries** - Media processing (optional)
- **Node.js** - UI building (optional)
See [docs/debian.md](docs/debian.md) for detailed installation instructions.
## Running ## Running

View File

@ -66,13 +66,7 @@
const file = input.files[0]; const file = input.files[0];
console.debug(file); console.debug(file);
const r_nip96 = document.querySelector("#method-nip96").checked;
const r_blossom = document.querySelector("#method-blossom").checked;
if (r_nip96) {
await uploadFilesNip96(file)
} else if (r_blossom) {
await uploadBlossom(file); await uploadBlossom(file);
}
} catch (ex) { } catch (ex) {
if (ex instanceof Error) { if (ex instanceof Error) {
alert(ex.message); alert(ex.message);
@ -147,16 +141,7 @@
Welcome to route96 Welcome to route96
</h1> </h1>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div>
<label>
NIP-96
<input type="radio" name="method" id="method-nip96"/>
</label>
<label>
Blossom
<input type="radio" name="method" id="method-blossom"/>
</label>
</div>
<div style="color: #ff8383;"> <div style="color: #ff8383;">
You must have a nostr extension for this to work You must have a nostr extension for this to work
</div> </div>
@ -164,7 +149,7 @@
<div> <div>
<input type="checkbox" id="no_transform"> <input type="checkbox" id="no_transform">
<label for="no_transform"> <label for="no_transform">
Disable compression (images) Disable compression (videos and images)
</label> </label>
</div> </div>
<div> <div>

View File

@ -1,7 +1,7 @@
-- Add migration script here -- Add migration script here
alter table users alter table users
add column paid_until timestamp, add column paid_until timestamp,
add column paid_size integer unsigned not null; add column paid_size bigint unsigned not null;
create table payments create table payments
( (
@ -11,7 +11,7 @@ create table payments
amount integer unsigned not null, amount integer unsigned not null,
is_paid bit(1) not null default 0, is_paid bit(1) not null default 0,
days_value integer unsigned not null, days_value integer unsigned not null,
size_value integer unsigned not null, size_value bigint unsigned not null,
settle_index integer unsigned, settle_index integer unsigned,
rate float, rate float,

229
src/bin/r96util.rs Normal file
View File

@ -0,0 +1,229 @@
use anyhow::{Context, Error, Result};
use clap::{Parser, Subcommand};
use config::Config;
use indicatif::{ProgressBar, ProgressStyle};
use log::{error, info};
use route96::db::{Database, FileUpload};
use route96::filesystem::{FileStore, FileSystemResult};
use route96::processing::probe_file;
use route96::settings::Settings;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::SystemTime;
use pretty_env_logger::env_logger;
use tokio::sync::Semaphore;
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
#[arg(long)]
pub config: Option<String>,
#[clap(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Check file hash matches filename / path
Check {
#[arg(long)]
delete: Option<bool>,
},
/// Import a directory into the filesystem
/// (does NOT import files into the database, use database-import command for that)
Import {
#[arg(long)]
from: PathBuf,
#[arg(long, default_missing_value = "true", num_args = 0..=1)]
probe_media: Option<bool>,
},
/// Import files from filesystem into database
DatabaseImport {
/// Don't actually import data and just print which files WOULD be imported
#[arg(long, default_missing_value = "true", num_args = 0..=1)]
dry_run: Option<bool>,
},
}
#[tokio::main]
async fn main() -> Result<(), Error> {
if std::env::var("RUST_LOG").is_err() {
unsafe { std::env::set_var("RUST_LOG", "info"); }
}
env_logger::init();
let args: Args = Args::parse();
let builder = Config::builder()
.add_source(config::File::with_name(if let Some(ref c) = args.config {
c.as_str()
} else {
"config.yaml"
}))
.add_source(config::Environment::with_prefix("APP"))
.build()?;
let settings: Settings = builder.try_deserialize()?;
match args.command {
Commands::Check { delete } => {
info!("Checking files in: {}", settings.storage_dir);
let fs = FileStore::new(settings.clone());
iter_files(&fs.storage_dir(), 4, |entry, p| {
let p = p.clone();
Box::pin(async move {
let id = if let Some(i) = id_from_path(&entry) {
i
} else {
p.set_message(format!("Skipping invalid file: {}", &entry.display()));
return Ok(());
};
let hash = FileStore::hash_file(&entry).await?;
if hash != id {
if delete.unwrap_or(false) {
p.set_message(format!("Deleting corrupt file: {}", &entry.display()));
tokio::fs::remove_file(&entry).await?;
} else {
p.set_message(format!("File is corrupted: {}", &entry.display()));
}
}
Ok(())
})
})
.await?;
}
Commands::Import { from, probe_media } => {
let fs = FileStore::new(settings.clone());
let db = Database::new(&settings.database).await?;
db.migrate().await?;
info!("Importing from: {}", fs.storage_dir().display());
iter_files(&from, 4, |entry, p| {
let fs = fs.clone();
let p = p.clone();
Box::pin(async move {
let mime = infer::get_from_path(&entry)?
.map(|m| m.mime_type())
.unwrap_or("application/octet-stream");
// test media is not corrupt
if probe_media.unwrap_or(true)
&& (mime.starts_with("image/") || mime.starts_with("video/"))
&& probe_file(&entry).is_err()
{
p.set_message(format!("Skipping media invalid file: {}", &entry.display()));
return Ok(());
}
let file = tokio::fs::File::open(&entry).await?;
let dst = fs.put(file, mime, false).await?;
match dst {
FileSystemResult::AlreadyExists(_) => {
p.set_message(format!("Duplicate file: {}", &entry.display()));
}
FileSystemResult::NewFile(_) => {
p.set_message(format!("Imported: {}", &entry.display()));
}
}
Ok(())
})
})
.await?;
}
Commands::DatabaseImport { dry_run } => {
let fs = FileStore::new(settings.clone());
let db = Database::new(&settings.database).await?;
db.migrate().await?;
info!("Importing to DB from: {}", fs.storage_dir().display());
iter_files(&fs.storage_dir(), 4, |entry, p| {
let db = db.clone();
let p = p.clone();
Box::pin(async move {
let id = if let Some(i) = id_from_path(&entry) {
i
} else {
p.set_message(format!("Skipping invalid file: {}", &entry.display()));
return Ok(());
};
let u = db.get_file(&id).await.context("db get_file")?;
if u.is_none() {
if !dry_run.unwrap_or(false) {
p.set_message(format!("Importing file: {}", &entry.display()));
let mime = infer::get_from_path(&entry)
.context("infer")?
.map(|m| m.mime_type())
.unwrap_or("application/octet-stream")
.to_string();
let meta = entry.metadata().context("file metadata")?;
let entry = FileUpload {
id,
name: None,
size: meta.len(),
mime_type: mime,
created: meta.created().unwrap_or(SystemTime::now()).into(),
width: None,
height: None,
blur_hash: None,
alt: None,
duration: None,
bitrate: None,
};
db.add_file(&entry, None).await.context("db add_file")?;
} else {
p.set_message(format!(
"[DRY-RUN] Importing file: {}",
&entry.display()
));
}
}
Ok(())
})
})
.await?;
}
}
Ok(())
}
fn id_from_path(path: &Path) -> Option<Vec<u8>> {
hex::decode(path.file_name()?.to_str()?).ok()
}
async fn iter_files<F>(p: &Path, threads: usize, mut op: F) -> Result<()>
where
F: FnMut(PathBuf, ProgressBar) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>,
{
let semaphore = Arc::new(Semaphore::new(threads));
info!("Scanning files: {}", p.display());
let entries = walkdir::WalkDir::new(p);
let dir = entries
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.collect::<Vec<_>>();
let p = ProgressBar::new(dir.len() as u64).with_style(ProgressStyle::with_template(
"{spinner} [{pos}/{len}] {msg}",
)?);
let mut all_tasks = vec![];
for entry in dir {
let _lock = semaphore.clone().acquire_owned().await?;
p.inc(1);
let fut = op(entry.path().to_path_buf(), p.clone());
all_tasks.push(tokio::spawn(async move {
if let Err(e) = fut.await {
error!("Error processing file: {} {}", entry.path().display(), e);
}
drop(_lock);
}));
}
for task in all_tasks {
task.await?;
}
p.finish_with_message("Done!");
Ok(())
}

View File

@ -139,14 +139,23 @@ impl Database {
.bind(pubkey) .bind(pubkey)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await?; .await?;
match res { let user_id = match res {
None => sqlx::query("select id from users where pubkey = ?") None => sqlx::query("select id from users where pubkey = ?")
.bind(pubkey) .bind(pubkey)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await? .await?
.try_get(0), .try_get(0)?,
Some(res) => res.try_get(0), Some(res) => res.try_get(0)?,
};
// Make the first user (ID 1) an admin
if user_id == 1 {
sqlx::query("update users set is_admin = 1 where id = 1")
.execute(&self.pool)
.await?;
} }
Ok(user_id)
} }
pub async fn get_user(&self, pubkey: &Vec<u8>) -> Result<User, Error> { pub async fn get_user(&self, pubkey: &Vec<u8>) -> Result<User, Error> {
@ -156,6 +165,13 @@ impl Database {
.await .await
} }
pub async fn get_user_by_id(&self, user_id: u64) -> Result<User, Error> {
sqlx::query_as("select * from users where id = ?")
.bind(user_id)
.fetch_one(&self.pool)
.await
}
pub async fn get_user_stats(&self, id: u64) -> Result<UserStats, Error> { pub async fn get_user_stats(&self, id: u64) -> Result<UserStats, Error> {
sqlx::query_as( sqlx::query_as(
"select cast(count(user_uploads.file) as unsigned integer) as file_count, \ "select cast(count(user_uploads.file) as unsigned integer) as file_count, \
@ -177,7 +193,7 @@ impl Database {
.try_get(0) .try_get(0)
} }
pub async fn add_file(&self, file: &FileUpload, user_id: u64) -> Result<(), Error> { pub async fn add_file(&self, file: &FileUpload, user_id: Option<u64>) -> Result<(), Error> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let q = sqlx::query("insert ignore into \ let q = sqlx::query("insert ignore into \
uploads(id,name,size,mime_type,blur_hash,width,height,alt,created,duration,bitrate) values(?,?,?,?,?,?,?,?,?,?,?)") uploads(id,name,size,mime_type,blur_hash,width,height,alt,created,duration,bitrate) values(?,?,?,?,?,?,?,?,?,?,?)")
@ -194,10 +210,12 @@ impl Database {
.bind(file.bitrate); .bind(file.bitrate);
tx.execute(q).await?; tx.execute(q).await?;
if let Some(uid) = user_id {
let q2 = sqlx::query("insert ignore into user_uploads(file,user_id) values(?,?)") let q2 = sqlx::query("insert ignore into user_uploads(file,user_id) values(?,?)")
.bind(&file.id) .bind(&file.id)
.bind(user_id); .bind(uid);
tx.execute(q2).await?; tx.execute(q2).await?;
}
#[cfg(feature = "labels")] #[cfg(feature = "labels")]
for lbl in &file.labels { for lbl in &file.labels {
@ -338,14 +356,49 @@ impl Database {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
// TODO: check space is not downgraded // Calculate time extension based on fractional quota value
// If user upgrades from 5GB to 10GB, their remaining time gets halved
// If user pays for 1GB on a 5GB plan, they get 1/5 of the normal time
let current_user = self.get_user_by_id(payment.user_id).await?;
sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, IFNULL(paid_until, current_timestamp)), paid_size = ? where id = ?") if let Some(paid_until) = current_user.paid_until {
if paid_until > chrono::Utc::now() {
// User has active subscription - calculate fractional time extension
let time_fraction = if current_user.paid_size > 0 {
payment.size_value as f64 / current_user.paid_size as f64
} else {
1.0 // If no existing quota, treat as 100%
};
let adjusted_days = (payment.days_value as f64 * time_fraction) as u64;
// Extend subscription time and upgrade quota if larger
let new_quota_size = std::cmp::max(current_user.paid_size, payment.size_value);
sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, paid_until), paid_size = ? where id = ?")
.bind(adjusted_days)
.bind(new_quota_size)
.bind(payment.user_id)
.execute(&mut *tx)
.await?;
} else {
// Expired subscription - set new quota and time
sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, current_timestamp), paid_size = ? where id = ?")
.bind(payment.days_value) .bind(payment.days_value)
.bind(payment.size_value) .bind(payment.size_value)
.bind(payment.user_id) .bind(payment.user_id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
}
} else {
// No existing subscription - set new quota
sqlx::query("update users set paid_until = TIMESTAMPADD(DAY, ?, current_timestamp), paid_size = ? where id = ?")
.bind(payment.days_value)
.bind(payment.size_value)
.bind(payment.user_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?; tx.commit().await?;

View File

@ -10,6 +10,7 @@ use anyhow::Error;
use anyhow::Result; use anyhow::Result;
#[cfg(feature = "media-compression")] #[cfg(feature = "media-compression")]
use ffmpeg_rs_raw::DemuxerInfo; use ffmpeg_rs_raw::DemuxerInfo;
#[cfg(feature = "media-compression")]
use ffmpeg_rs_raw::StreamInfo; use ffmpeg_rs_raw::StreamInfo;
#[cfg(feature = "media-compression")] #[cfg(feature = "media-compression")]
use rocket::form::validate::Contains; use rocket::form::validate::Contains;

View File

@ -30,7 +30,7 @@ pub enum PaymentUnit {
impl PaymentUnit { impl PaymentUnit {
/// Get the total size from a number of units /// Get the total size from a number of units
pub fn to_size(&self, units: f32) -> u64 { pub fn to_size(&self, units: f32) -> u64 {
(1000f32 * 1000f32 * 1000f32 * units) as u64 (1024f32 * 1024f32 * 1024f32 * units) as u64
} }
} }

View File

@ -105,7 +105,12 @@ impl WebpProcessor {
let mut decoder = Decoder::new(); let mut decoder = Decoder::new();
decoder.setup_decoder(image_stream, None)?; decoder.setup_decoder(image_stream, None)?;
while let Ok((mut pkt, _stream)) = input.get_packet() { while let Ok((mut pkt, _)) = input.get_packet() {
// skip packets not in the image stream
if (*pkt).stream_index != image_stream.index as i32 {
av_packet_free(&mut pkt);
continue;
}
let mut frame_save: *mut AVFrame = ptr::null_mut(); let mut frame_save: *mut AVFrame = ptr::null_mut();
for (mut frame, _stream) in decoder.decode_pkt(pkt)? { for (mut frame, _stream) in decoder.decode_pkt(pkt)? {
if frame_save.is_null() { if frame_save.is_null() {

View File

@ -1,5 +1,5 @@
use crate::auth::nip98::Nip98Auth; use crate::auth::nip98::Nip98Auth;
use crate::db::{Database, FileUpload, User, Report}; use crate::db::{Database, FileUpload, Report, User};
use crate::routes::{Nip94Event, PagedResult}; use crate::routes::{Nip94Event, PagedResult};
use crate::settings::Settings; use crate::settings::Settings;
use rocket::serde::json::Json; use rocket::serde::json::Json;
@ -8,7 +8,12 @@ use rocket::{routes, Responder, Route, State};
use sqlx::{Error, QueryBuilder, Row}; use sqlx::{Error, QueryBuilder, Row};
pub fn admin_routes() -> Vec<Route> { pub fn admin_routes() -> Vec<Route> {
routes![admin_list_files, admin_get_self, admin_list_reports, admin_acknowledge_report] routes![
admin_list_files,
admin_get_self,
admin_list_reports,
admin_acknowledge_report,
]
} }
#[derive(Serialize, Default)] #[derive(Serialize, Default)]
@ -71,7 +76,11 @@ pub struct AdminNip94File {
} }
#[rocket::get("/self")] #[rocket::get("/self")]
async fn admin_get_self(auth: Nip98Auth, db: &State<Database>, settings: &State<Settings>) -> AdminResponse<SelfUser> { async fn admin_get_self(
auth: Nip98Auth,
db: &State<Database>,
settings: &State<Settings>,
) -> AdminResponse<SelfUser> {
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
match db.get_user(&pubkey_vec).await { match db.get_user(&pubkey_vec).await {
Ok(user) => { Ok(user) => {
@ -84,7 +93,9 @@ async fn admin_get_self(auth: Nip98Auth, db: &State<Database>, settings: &State<
#[cfg(feature = "payments")] #[cfg(feature = "payments")]
let (free_quota, total_available_quota) = { let (free_quota, total_available_quota) = {
let free_quota = settings.payments.as_ref() let free_quota = settings
.payments
.as_ref()
.and_then(|p| p.free_quota_bytes) .and_then(|p| p.free_quota_bytes)
.unwrap_or(104857600); .unwrap_or(104857600);
let mut total_available = free_quota; let mut total_available = free_quota;
@ -223,8 +234,9 @@ impl Database {
) -> Result<(Vec<(FileUpload, Vec<User>)>, i64), Error> { ) -> Result<(Vec<(FileUpload, Vec<User>)>, i64), Error> {
let mut q = QueryBuilder::new("select u.* from uploads u "); let mut q = QueryBuilder::new("select u.* from uploads u ");
if let Some(m) = mime_type { if let Some(m) = mime_type {
q.push("where u.mime_type = "); q.push("where INSTR(u.mime_type,");
q.push_bind(m); q.push_bind(m);
q.push(") > 0");
} }
q.push(" order by u.created desc limit "); q.push(" order by u.created desc limit ");
q.push_bind(limit); q.push_bind(limit);

View File

@ -15,8 +15,9 @@ use rocket::{routes, Data, Request, Response, Route, State};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncRead; use tokio::io::AsyncRead;
use tokio_util::io::StreamReader; use tokio_util::io::StreamReader;
use url::Url;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct BlobDescriptor { pub struct BlobDescriptor {
pub url: String, pub url: String,
@ -24,7 +25,7 @@ pub struct BlobDescriptor {
pub size: u64, pub size: u64,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")] #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>, pub mime_type: Option<String>,
pub created: u64, pub uploaded: u64,
#[serde(rename = "nip94", skip_serializing_if = "Option::is_none")] #[serde(rename = "nip94", skip_serializing_if = "Option::is_none")]
pub nip94: Option<Vec<Vec<String>>>, pub nip94: Option<Vec<Vec<String>>>,
} }
@ -44,7 +45,7 @@ impl BlobDescriptor {
sha256: id_hex, sha256: id_hex,
size: value.size, size: value.size,
mime_type: Some(value.mime_type.clone()), mime_type: Some(value.mime_type.clone()),
created: value.created.timestamp() as u64, uploaded: value.created.timestamp() as u64,
nip94: Some(Nip94Event::from_upload(settings, value).tags), nip94: Some(Nip94Event::from_upload(settings, value).tags),
} }
} }
@ -225,15 +226,25 @@ async fn mirror(
settings: &State<Settings>, settings: &State<Settings>,
req: Json<MirrorRequest>, req: Json<MirrorRequest>,
) -> BlossomResponse { ) -> BlossomResponse {
if !check_method(&auth.event, "mirror") { if !check_method(&auth.event, "upload") {
return BlossomResponse::error("Invalid request method tag"); return BlossomResponse::error("Invalid request method tag");
} }
if let Some(e) = check_whitelist(&auth, settings) { if let Some(e) = check_whitelist(&auth, settings) {
return e; return e;
} }
let url = match Url::parse(&req.url) {
Ok(u) => u,
Err(e) => return BlossomResponse::error(format!("Invalid URL: {}", e)),
};
let hash = url
.path_segments()
.and_then(|mut c| c.next_back())
.and_then(|s| s.split(".").next());
// download file // download file
let rsp = match reqwest::get(&req.url).await { let rsp = match reqwest::get(url.clone()).await {
Err(e) => { Err(e) => {
error!("Error downloading file: {}", e); error!("Error downloading file: {}", e);
return BlossomResponse::error("Failed to mirror file"); return BlossomResponse::error("Failed to mirror file");
@ -250,16 +261,19 @@ async fn mirror(
let pubkey = auth.event.pubkey.to_bytes().to_vec(); let pubkey = auth.event.pubkey.to_bytes().to_vec();
process_stream( process_stream(
StreamReader::new(rsp.bytes_stream().map(|result| { StreamReader::new(
result.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)) rsp.bytes_stream()
})), .map(|result| result.map_err(std::io::Error::other)),
),
&mime_type, &mime_type,
&None, &None,
&pubkey, &pubkey,
false, false,
0, // No size info for mirror
fs, fs,
db, db,
settings, settings,
hash.and_then(|h| hex::decode(h).ok()),
) )
.await .await
} }
@ -345,43 +359,38 @@ async fn process_upload(
None None
} }
}); });
let size = auth.event.tags.iter().find_map(|t| { let size_tag = auth.event.tags.iter().find_map(|t| {
if t.kind() == TagKind::Size { if t.kind() == TagKind::Size {
t.content().and_then(|v| v.parse::<u64>().ok()) t.content().and_then(|v| v.parse::<u64>().ok())
} else { } else {
None None
} }
}); });
if let Some(z) = size {
if z > settings.max_upload_bytes { let size = size_tag.or(auth.x_content_length).unwrap_or(0);
if size > 0 && size > settings.max_upload_bytes {
return BlossomResponse::error("File too large"); return BlossomResponse::error("File too large");
} }
}
// check whitelist // check whitelist
if let Some(e) = check_whitelist(&auth, settings) { if let Some(e) = check_whitelist(&auth, settings) {
return e; return e;
} }
// check quota // check quota (only if payments are configured)
#[cfg(feature = "payments")] #[cfg(feature = "payments")]
if let Some(upload_size) = size { if let Some(payment_config) = &settings.payments {
let free_quota = settings let free_quota = payment_config.free_quota_bytes.unwrap_or(104857600); // Default to 100MB
.payments
.as_ref()
.and_then(|p| p.free_quota_bytes)
.unwrap_or(104857600); // Default to 100MB
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
match db if size > 0 {
.check_user_quota(&pubkey_vec, upload_size, free_quota) match db.check_user_quota(&pubkey_vec, size, free_quota).await {
.await
{
Ok(false) => return BlossomResponse::error("Upload would exceed quota"), Ok(false) => return BlossomResponse::error("Upload would exceed quota"),
Err(_) => return BlossomResponse::error("Failed to check quota"), Err(_) => return BlossomResponse::error("Failed to check quota"),
Ok(true) => {} // Quota check passed Ok(true) => {} // Quota check passed
} }
} }
}
process_stream( process_stream(
data.open(ByteUnit::Byte(settings.max_upload_bytes)), data.open(ByteUnit::Byte(settings.max_upload_bytes)),
@ -391,9 +400,11 @@ async fn process_upload(
&name, &name,
&auth.event.pubkey.to_bytes().to_vec(), &auth.event.pubkey.to_bytes().to_vec(),
compress, compress,
size,
fs, fs,
db, db,
settings, settings,
None,
) )
.await .await
} }
@ -404,9 +415,11 @@ async fn process_stream<'p, S>(
name: &Option<&str>, name: &Option<&str>,
pubkey: &Vec<u8>, pubkey: &Vec<u8>,
compress: bool, compress: bool,
size: u64,
fs: &State<FileStore>, fs: &State<FileStore>,
db: &State<Database>, db: &State<Database>,
settings: &State<Settings>, settings: &State<Settings>,
expect_hash: Option<Vec<u8>>,
) -> BlossomResponse ) -> BlossomResponse
where where
S: AsyncRead + Unpin + 'p, S: AsyncRead + Unpin + 'p,
@ -415,6 +428,15 @@ where
Ok(FileSystemResult::NewFile(blob)) => { Ok(FileSystemResult::NewFile(blob)) => {
let mut ret: FileUpload = (&blob).into(); let mut ret: FileUpload = (&blob).into();
// check expected hash (mirroring)
if let Some(h) = expect_hash {
if h != ret.id {
if let Err(e) = tokio::fs::remove_file(fs.get(&ret.id)).await {
log::warn!("Failed to cleanup file: {}", e);
}
return BlossomResponse::error("Mirror request failed, server responses with invalid file content (hash mismatch)");
}
}
// update file data before inserting // update file data before inserting
ret.name = name.map(|s| s.to_string()); ret.name = name.map(|s| s.to_string());
@ -425,7 +447,7 @@ where
_ => return BlossomResponse::error("File not found"), _ => return BlossomResponse::error("File not found"),
}, },
Err(e) => { Err(e) => {
error!("{}", e.to_string()); error!("{}", e);
return BlossomResponse::error(format!("Error saving file (disk): {}", e)); return BlossomResponse::error(format!("Error saving file (disk): {}", e));
} }
}; };
@ -436,8 +458,34 @@ where
return BlossomResponse::error(format!("Failed to save file (db): {}", e)); return BlossomResponse::error(format!("Failed to save file (db): {}", e));
} }
}; };
if let Err(e) = db.add_file(&upload, user_id).await {
error!("{}", e.to_string()); // Post-upload quota check if we didn't have size information before upload (only if payments are configured)
#[cfg(feature = "payments")]
if size == 0 {
if let Some(payment_config) = &settings.payments {
let free_quota = payment_config.free_quota_bytes.unwrap_or(104857600); // Default to 100MB
match db.check_user_quota(pubkey, upload.size, free_quota).await {
Ok(false) => {
// Clean up the uploaded file if quota exceeded
if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await {
log::warn!("Failed to cleanup quota-exceeding file: {}", e);
}
return BlossomResponse::error("Upload would exceed quota");
}
Err(_) => {
// Clean up on quota check error
if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await {
log::warn!("Failed to cleanup file after quota check error: {}", e);
}
return BlossomResponse::error("Failed to check quota");
}
Ok(true) => {} // Quota check passed
}
}
}
if let Err(e) = db.add_file(&upload, Some(user_id)).await {
error!("{}", e);
BlossomResponse::error(format!("Error saving file (db): {}", e)) BlossomResponse::error(format!("Error saving file (db): {}", e))
} else { } else {
BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from_upload(settings, &upload))) BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from_upload(settings, &upload)))

View File

@ -423,7 +423,8 @@ pub async fn get_blob_thumb(
if !thumb_file.exists() { if !thumb_file.exists() {
let mut p = WebpProcessor::new(); let mut p = WebpProcessor::new();
if p.thumbnail(&file_path, &thumb_file).is_err() { if let Err(e) = p.thumbnail(&file_path, &thumb_file) {
warn!("Failed to generate thumbnail: {}", e);
return Err(Status::InternalServerError); return Err(Status::InternalServerError);
} }
}; };

View File

@ -174,12 +174,8 @@ async fn upload(
settings: &State<Settings>, settings: &State<Settings>,
form: Form<Nip96Form<'_>>, form: Form<Nip96Form<'_>>,
) -> Nip96Response { ) -> Nip96Response {
if let Some(size) = auth.content_length { let upload_size = auth.content_length.or(Some(form.size)).unwrap_or(0);
if size > settings.max_upload_bytes { if upload_size > 0 && upload_size > settings.max_upload_bytes {
return Nip96Response::error("File too large");
}
}
if form.size > settings.max_upload_bytes {
return Nip96Response::error("File too large"); return Nip96Response::error("File too large");
} }
let file = match form.file.open().await { let file = match form.file.open().await {
@ -193,7 +189,8 @@ async fn upload(
} }
// account for upload speeds as slow as 1MB/s (8 Mbps) // account for upload speeds as slow as 1MB/s (8 Mbps)
let mbs = form.size / 1.megabytes().as_u64(); let size_for_timing = if upload_size > 0 { upload_size } else { form.size };
let mbs = size_for_timing / 1.megabytes().as_u64();
let max_time = 60.max(mbs); let max_time = 60.max(mbs);
if auth.event.created_at < Timestamp::now().sub(Duration::from_secs(max_time)) { if auth.event.created_at < Timestamp::now().sub(Duration::from_secs(max_time)) {
return Nip96Response::error("Auth event timestamp out of range"); return Nip96Response::error("Auth event timestamp out of range");
@ -208,25 +205,36 @@ async fn upload(
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
// check quota // check quota (only if payments are configured)
#[cfg(feature = "payments")] #[cfg(feature = "payments")]
{ if let Some(payment_config) = &settings.payments {
let free_quota = settings.payments.as_ref() let free_quota = payment_config.free_quota_bytes
.and_then(|p| p.free_quota_bytes)
.unwrap_or(104857600); // Default to 100MB .unwrap_or(104857600); // Default to 100MB
match db.check_user_quota(&pubkey_vec, form.size, free_quota).await { if upload_size > 0 {
match db.check_user_quota(&pubkey_vec, upload_size, free_quota).await {
Ok(false) => return Nip96Response::error("Upload would exceed quota"), Ok(false) => return Nip96Response::error("Upload would exceed quota"),
Err(_) => return Nip96Response::error("Failed to check quota"), Err(_) => return Nip96Response::error("Failed to check quota"),
Ok(true) => {} // Quota check passed Ok(true) => {} // Quota check passed
} }
} }
}
let upload = match fs let upload = match fs
.put(file, content_type, !form.no_transform.unwrap_or(false)) .put(file, content_type, !form.no_transform.unwrap_or(false))
.await .await
{ {
Ok(FileSystemResult::NewFile(blob)) => { Ok(FileSystemResult::NewFile(blob)) => {
let mut upload: FileUpload = (&blob).into(); let mut upload: FileUpload = (&blob).into();
// Validate file size after upload if no pre-upload size was available
if upload_size == 0 && upload.size > settings.max_upload_bytes {
// Clean up the uploaded file
if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await {
log::warn!("Failed to cleanup oversized file: {}", e);
}
return Nip96Response::error("File too large");
}
upload.name = form.caption.map(|cap| cap.to_string()); upload.name = form.caption.map(|cap| cap.to_string());
upload.alt = form.alt.as_ref().map(|s| s.to_string()); upload.alt = form.alt.as_ref().map(|s| s.to_string());
upload upload
@ -236,7 +244,7 @@ async fn upload(
_ => return Nip96Response::error("File not found"), _ => return Nip96Response::error("File not found"),
}, },
Err(e) => { Err(e) => {
error!("{}", e.to_string()); error!("{}", e);
return Nip96Response::error(&format!("Could not save file: {}", e)); return Nip96Response::error(&format!("Could not save file: {}", e));
} }
}; };
@ -246,8 +254,35 @@ async fn upload(
Err(e) => return Nip96Response::error(&format!("Could not save user: {}", e)), Err(e) => return Nip96Response::error(&format!("Could not save user: {}", e)),
}; };
if let Err(e) = db.add_file(&upload, user_id).await { // Post-upload quota check if we didn't have size information before upload (only if payments are configured)
error!("{}", e.to_string()); #[cfg(feature = "payments")]
if upload_size == 0 {
if let Some(payment_config) = &settings.payments {
let free_quota = payment_config.free_quota_bytes
.unwrap_or(104857600); // Default to 100MB
match db.check_user_quota(&pubkey_vec, upload.size, free_quota).await {
Ok(false) => {
// Clean up the uploaded file if quota exceeded
if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await {
log::warn!("Failed to cleanup quota-exceeding file: {}", e);
}
return Nip96Response::error("Upload would exceed quota");
}
Err(_) => {
// Clean up on quota check error
if let Err(e) = tokio::fs::remove_file(fs.get(&upload.id)).await {
log::warn!("Failed to cleanup file after quota check error: {}", e);
}
return Nip96Response::error("Failed to check quota");
}
Ok(true) => {} // Quota check passed
}
}
}
if let Err(e) = db.add_file(&upload, Some(user_id)).await {
error!("{}", e);
return Nip96Response::error(&format!("Could not save file (db): {}", e)); return Nip96Response::error(&format!("Could not save file (db): {}", e));
} }
Nip96Response::UploadResult(Json(Nip96UploadResult::from_upload(settings, &upload))) Nip96Response::UploadResult(Json(Nip96UploadResult::from_upload(settings, &upload)))

View File

@ -16,12 +16,14 @@
"@snort/system-react": "^1.5.1", "@snort/system-react": "^1.5.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^7.6.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.9.0", "eslint": "^9.9.0",

View File

@ -1,12 +1,23 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Header from "./views/header"; import Header from "./views/header";
import Upload from "./views/upload"; import Upload from "./views/upload";
import Admin from "./views/admin";
function App() { function App() {
return ( return (
<div className="flex flex-col gap-4 mx-auto mt-4 max-w-[1920px] px-10"> <Router>
<div className="min-h-screen bg-gray-900">
<div className="max-lg:px-6">
<Header /> <Header />
<Upload /> <main className="py-8">
<Routes>
<Route path="/" element={<Upload />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</main>
</div> </div>
</div>
</Router>
); );
} }

View File

@ -22,12 +22,12 @@ export default function Button({
} }
return ( return (
<button <button
className={`py-2 px-4 rounded-md border-0 text-sm font-semibold bg-neutral-700 hover:bg-neutral-600 ${className} ${props.disabled || loading ? "opacity-50" : ""}`} className={`${className} ${props.disabled || loading ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={doClick} onClick={doClick}
{...props} {...props}
disabled={loading || (props.disabled ?? false)} disabled={loading || (props.disabled ?? false)}
> >
{children} {loading ? "..." : children}
</button> </button>
); );
} }

View File

@ -0,0 +1,370 @@
import { useState, useEffect } from "react";
import { Blossom } from "../upload/blossom";
import { FormatBytes } from "../const";
import Button from "./button";
import usePublisher from "../hooks/publisher";
import useLogin from "../hooks/login";
interface FileMirrorSuggestion {
sha256: string;
url: string;
size: number;
mime_type?: string;
available_on: string[];
missing_from: string[];
}
interface MirrorSuggestionsProps {
servers: string[];
}
interface MirrorProgress {
total: number;
completed: number;
failed: number;
errors: string[];
}
export default function MirrorSuggestions({ servers }: MirrorSuggestionsProps) {
const [suggestions, setSuggestions] = useState<FileMirrorSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [mirrorAllProgress, setMirrorAllProgress] = useState<MirrorProgress | null>(null);
const pub = usePublisher();
const login = useLogin();
useEffect(() => {
if (servers.length > 1 && pub && login?.pubkey) {
fetchSuggestions();
}
}, [servers, pub, login?.pubkey]);
async function fetchSuggestions() {
if (!pub || !login?.pubkey) return;
try {
setLoading(true);
setError(undefined);
// Capture the servers list at the start to avoid race conditions
const serverList = [...servers];
if (serverList.length <= 1) {
setLoading(false);
return;
}
const fileMap: Map<string, FileMirrorSuggestion> = new Map();
// Fetch files from each server
for (const serverUrl of serverList) {
try {
const blossom = new Blossom(serverUrl, pub);
const files = await blossom.list(login.pubkey);
for (const file of files) {
const suggestion = fileMap.get(file.sha256);
if (suggestion) {
suggestion.available_on.push(serverUrl);
} else {
fileMap.set(file.sha256, {
sha256: file.sha256,
url: file.url || "",
size: file.size,
mime_type: file.type,
available_on: [serverUrl],
missing_from: [],
});
}
}
} catch (e) {
console.error(`Failed to fetch files from ${serverUrl}:`, e);
// Continue with other servers instead of failing completely
}
}
// Determine missing servers for each file using the captured server list
for (const suggestion of fileMap.values()) {
for (const serverUrl of serverList) {
if (!suggestion.available_on.includes(serverUrl)) {
suggestion.missing_from.push(serverUrl);
}
}
}
// Filter to only files that are missing from at least one server and available on at least one
const filteredSuggestions = Array.from(fileMap.values()).filter(
s => s.missing_from.length > 0 && s.available_on.length > 0
);
setSuggestions(filteredSuggestions);
} catch (e) {
if (e instanceof Error) {
setError(e.message);
} else {
setError("Failed to fetch mirror suggestions");
}
} finally {
setLoading(false);
}
}
async function mirrorAll() {
if (!pub || suggestions.length === 0) return;
// Calculate total operations needed
const totalOperations = suggestions.reduce((total, suggestion) =>
total + suggestion.missing_from.length, 0
);
setMirrorAllProgress({
total: totalOperations,
completed: 0,
failed: 0,
errors: []
});
let completed = 0;
let failed = 0;
const errors: string[] = [];
// Mirror all files to all missing servers
for (const suggestion of suggestions) {
for (const targetServer of suggestion.missing_from) {
try {
const blossom = new Blossom(targetServer, pub);
await blossom.mirror(suggestion.url);
completed++;
setMirrorAllProgress(prev => prev ? {
...prev,
completed: completed,
failed: failed
} : null);
// Update suggestions in real-time
setSuggestions(prev =>
prev.map(s =>
s.sha256 === suggestion.sha256
? {
...s,
available_on: [...s.available_on, targetServer],
missing_from: s.missing_from.filter(server => server !== targetServer)
}
: s
).filter(s => s.missing_from.length > 0)
);
} catch (e) {
failed++;
const errorMessage = e instanceof Error ? e.message : "Unknown error";
const serverHost = new URL(targetServer).hostname;
errors.push(`${serverHost}: ${errorMessage}`);
setMirrorAllProgress(prev => prev ? {
...prev,
completed: completed,
failed: failed,
errors: [...errors]
} : null);
}
}
}
// Keep progress visible for a moment before clearing
setTimeout(() => {
setMirrorAllProgress(null);
}, 3000);
}
// Calculate coverage statistics
const totalFiles = suggestions.length;
const totalMirrorOperations = suggestions.reduce((total, suggestion) =>
total + suggestion.missing_from.length, 0
);
const totalSize = suggestions.reduce((total, suggestion) => total + suggestion.size, 0);
// Calculate coverage per server
const serverCoverage = servers.map(serverUrl => {
const filesOnServer = suggestions.filter(s => s.available_on.includes(serverUrl)).length;
const totalFilesAcrossAllServers = new Set(suggestions.map(s => s.sha256)).size;
const coveragePercentage = totalFilesAcrossAllServers > 0 ?
Math.round((filesOnServer / totalFilesAcrossAllServers) * 100) : 100;
return {
url: serverUrl,
hostname: new URL(serverUrl).hostname,
filesCount: filesOnServer,
totalFiles: totalFilesAcrossAllServers,
coveragePercentage
};
});
if (servers.length <= 1) {
return null; // No suggestions needed for single server
}
if (loading) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400">Loading mirror suggestions...</p>
</div>
);
}
if (error) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-4">
{error}
</div>
<Button onClick={fetchSuggestions} className="btn-secondary">
Retry
</Button>
</div>
);
}
if (suggestions.length === 0) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Suggestions</h3>
<p className="text-gray-400">All your files are synchronized across all servers.</p>
</div>
);
}
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Mirror Coverage</h3>
{/* Coverage Summary */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-blue-400">{totalFiles}</div>
<div className="text-xs text-gray-400">Files to Mirror</div>
</div>
<div>
<div className="text-2xl font-bold text-orange-400">{totalMirrorOperations}</div>
<div className="text-xs text-gray-400">Operations Needed</div>
</div>
<div>
<div className="text-2xl font-bold text-green-400">{FormatBytes(totalSize)}</div>
<div className="text-xs text-gray-400">Total Size</div>
</div>
</div>
</div>
{/* Server Coverage */}
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-6">
<h4 className="text-sm font-semibold text-gray-300 mb-3">Coverage by Server</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{serverCoverage.map((server) => (
<div key={server.url} className="bg-gray-750 border border-gray-600 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300 truncate">
{server.hostname}
</span>
<span
className={`text-sm font-semibold ${server.coveragePercentage === 100
? "text-green-400"
: server.coveragePercentage >= 80
? "text-yellow-400"
: "text-red-400"
}`}
>
{server.coveragePercentage}%
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${server.coveragePercentage === 100
? "bg-green-500"
: server.coveragePercentage >= 80
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{
width: `${server.coveragePercentage}%`,
}}
></div>
</div>
<div className="text-xs text-gray-400 text-center">
{server.filesCount} / {server.totalFiles} files
</div>
</div>
))}
</div>
</div>
{/* Mirror All Section */}
{!mirrorAllProgress ? (
<div className="text-center">
<p className="text-gray-400 mb-4">
{totalFiles} files need to be synchronized across your servers
</p>
<Button
onClick={mirrorAll}
className="btn-primary"
disabled={totalMirrorOperations === 0}
>
Mirror Everything
</Button>
</div>
) : (
<div className="space-y-4">
{/* Progress Bar */}
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-gray-400">Progress</span>
<span className="text-gray-400">
{mirrorAllProgress.completed + mirrorAllProgress.failed} / {mirrorAllProgress.total}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${((mirrorAllProgress.completed + mirrorAllProgress.failed) / mirrorAllProgress.total) * 100}%`
}}
/>
</div>
</div>
{/* Status Summary */}
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-green-400 font-semibold">{mirrorAllProgress.completed}</div>
<div className="text-gray-400">Completed</div>
</div>
<div>
<div className="text-red-400 font-semibold">{mirrorAllProgress.failed}</div>
<div className="text-gray-400">Failed</div>
</div>
<div>
<div className="text-gray-400 font-semibold">
{mirrorAllProgress.total - mirrorAllProgress.completed - mirrorAllProgress.failed}
</div>
<div className="text-gray-400">Remaining</div>
</div>
</div>
{/* Errors */}
{mirrorAllProgress.errors.length > 0 && (
<div className="bg-red-900/20 border border-red-800 rounded-lg p-3">
<h4 className="text-red-400 font-semibold mb-2">Errors ({mirrorAllProgress.errors.length})</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{mirrorAllProgress.errors.map((error, index) => (
<div key={index} className="text-red-300 text-xs">{error}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,16 +1,26 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Button from "./button"; import Button from "./button";
import { PaymentInfo, PaymentRequest, Route96 } from "../upload/admin"; import {
PaymentInfo,
PaymentRequest,
Route96,
AdminSelf,
} from "../upload/admin";
interface PaymentFlowProps { interface PaymentFlowProps {
route96: Route96; route96: Route96;
onPaymentRequested?: (paymentRequest: string) => void; onPaymentRequested?: (paymentRequest: string) => void;
userInfo?: AdminSelf;
} }
export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlowProps) { export default function PaymentFlow({
route96,
onPaymentRequested,
userInfo,
}: PaymentFlowProps) {
const [paymentInfo, setPaymentInfo] = useState<PaymentInfo | null>(null); const [paymentInfo, setPaymentInfo] = useState<PaymentInfo | null>(null);
const [units, setUnits] = useState<number>(1); const [gigabytes, setGigabytes] = useState<number>(1);
const [quantity, setQuantity] = useState<number>(1); const [months, setMonths] = useState<number>(1);
const [paymentRequest, setPaymentRequest] = useState<string>(""); const [paymentRequest, setPaymentRequest] = useState<string>("");
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -21,6 +31,17 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow
} }
}, [paymentInfo]); }, [paymentInfo]);
// Set default gigabytes to user's current quota
useEffect(() => {
if (userInfo?.quota && userInfo.quota > 0) {
// Convert from bytes to GB using 1024^3 (MiB)
const currentQuotaGB = Math.round(userInfo.quota / (1024 * 1024 * 1024));
if (currentQuotaGB > 0) {
setGigabytes(currentQuotaGB);
}
}
}, [userInfo]);
async function loadPaymentInfo() { async function loadPaymentInfo() {
try { try {
const info = await route96.getPaymentInfo(); const info = await route96.getPaymentInfo();
@ -41,7 +62,7 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow
setError(""); setError("");
try { try {
const request: PaymentRequest = { units, quantity }; const request: PaymentRequest = { units: gigabytes, quantity: months };
const response = await route96.requestPayment(request); const response = await route96.requestPayment(request);
setPaymentRequest(response.pr); setPaymentRequest(response.pr);
onPaymentRequested?.(response.pr); onPaymentRequested?.(response.pr);
@ -57,71 +78,89 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow
} }
if (error && !paymentInfo) { if (error && !paymentInfo) {
return <div className="text-red-500">Payment not available: {error}</div>; return <div className="text-red-400">Payment not available: {error}</div>;
} }
if (!paymentInfo) { if (!paymentInfo) {
return <div>Loading payment info...</div>; return <div className="text-gray-400">Loading payment info...</div>;
} }
const totalCost = paymentInfo.cost.amount * units * quantity; const totalCostBTC = paymentInfo.cost.amount * gigabytes * months;
const totalCostSats = Math.round(totalCostBTC * 100000000); // Convert BTC to sats
function formatStorageUnit(unit: string): string {
if (
unit.toLowerCase().includes("gbspace") ||
unit.toLowerCase().includes("gb")
) {
return "GB";
}
return unit;
}
return ( return (
<div className="bg-neutral-700 p-4 rounded-lg"> <div className="card">
<h3 className="text-lg font-bold mb-4">Top Up Account</h3> <h3 className="text-lg font-bold mb-4">Top Up Account</h3>
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="space-y-4 mb-6">
<div> <div className="text-center">
<label className="block text-sm font-medium mb-1"> <div className="text-2xl font-bold text-gray-100 mb-2">
Units ({paymentInfo.unit}) {gigabytes} {formatStorageUnit(paymentInfo.unit)} for {months} month
</label> {months > 1 ? "s" : ""}
<input </div>
type="number" <div className="text-lg text-blue-400 font-semibold">
min="0.1" {totalCostSats.toLocaleString()} sats
step="0.1" </div>
value={units}
onChange={(e) => setUnits(parseFloat(e.target.value) || 0)}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded"
/>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1"> <label className="block text-sm font-medium mb-2 text-gray-300">
Quantity Storage ({formatStorageUnit(paymentInfo.unit)})
</label> </label>
<input <input
type="number" type="number"
min="1" min="1"
value={quantity} step="1"
onChange={(e) => setQuantity(parseInt(e.target.value) || 1)} value={gigabytes}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded" onChange={(e) => setGigabytes(parseInt(e.target.value) || 1)}
className="input w-full text-center text-lg"
/> />
</div> </div>
</div>
<div className="mb-4"> <div>
<div className="text-sm text-neutral-300"> <label className="block text-sm font-medium mb-2 text-gray-300">
Cost: {totalCost.toFixed(8)} {paymentInfo.cost.currency} per {paymentInfo.interval} Duration (months)
</label>
<input
type="number"
min="1"
step="1"
value={months}
onChange={(e) => setMonths(parseInt(e.target.value) || 1)}
className="input w-full text-center text-lg"
/>
</div>
</div> </div>
</div> </div>
<Button <Button
onClick={requestPayment} onClick={requestPayment}
disabled={loading || units <= 0 || quantity <= 0} disabled={loading || gigabytes <= 0 || months <= 0}
className="w-full mb-4" className="btn-primary w-full mb-4"
> >
{loading ? "Processing..." : "Generate Payment Request"} {loading ? "Processing..." : "Generate Payment Request"}
</Button> </Button>
{error && <div className="text-red-500 text-sm mb-4">{error}</div>} {error && <div className="text-red-400 text-sm mb-4">{error}</div>}
{paymentRequest && ( {paymentRequest && (
<div className="bg-neutral-800 p-4 rounded"> <div className="bg-gray-800 p-4 rounded-lg border border-gray-700">
<div className="text-sm font-medium mb-2">Lightning Invoice:</div> <div className="text-sm font-medium mb-2">Lightning Invoice:</div>
<div className="font-mono text-xs break-all bg-neutral-900 p-2 rounded"> <div className="font-mono text-xs break-all bg-gray-900 p-2 rounded">
{paymentRequest} {paymentRequest}
</div> </div>
<div className="text-xs text-neutral-400 mt-2"> <div className="text-xs text-gray-400 mt-2">
Copy this invoice to your Lightning wallet to complete payment Copy this invoice to your Lightning wallet to complete payment
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { hexToBech32 } from "@snort/shared"; import { hexToBech32 } from "@snort/shared";
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { useMemo } from "react";
export default function Profile({ export default function Profile({
link, link,
@ -11,15 +12,28 @@ export default function Profile({
size?: number; size?: number;
showName?: boolean; showName?: boolean;
}) { }) {
const profile = useUserProfile(link.id); const linkId = useMemo(() => link.id, [link.id]);
const profile = useUserProfile(linkId);
const s = size ?? 40; const s = size ?? 40;
return ( return (
<a className="flex gap-2 items-center" href={`https://snort.social/${link.encode()}`} target="_blank"> <a
className="flex gap-2 items-center"
href={`https://snort.social/${link.encode()}`}
target="_blank"
>
<img <img
src={profile?.picture} src={
profile?.picture ||
`https://nostr.api.v0l.io/api/v1/avatar/cyberpunks/${link.id}`
}
alt={profile?.display_name || profile?.name || "User avatar"}
width={s} width={s}
height={s} height={s}
className="rounded-full object-fit object-center" className="rounded-full object-fit owbject-center"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = `https://nostr.api.v0l.io/api/v1/avatar/cyberpunks/${link.id}`;
}}
/> />
{(showName ?? true) && ( {(showName ?? true) && (
<div> <div>

View File

@ -0,0 +1,62 @@
import { UploadProgress, formatSpeed, formatTime } from "../upload/progress";
import { FormatBytes } from "../const";
interface ProgressBarProps {
progress: UploadProgress;
fileName?: string;
}
export default function ProgressBar({ progress, fileName }: ProgressBarProps) {
const {
percentage,
bytesUploaded,
totalBytes,
averageSpeed,
estimatedTimeRemaining,
} = progress;
return (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-blue-400">
{fileName ? `Uploading ${fileName}` : "Uploading..."}
</h4>
<span className="text-sm text-gray-400">
{percentage.toFixed(1)}%
</span>
</div>
{/* Progress Bar */}
<div className="w-full bg-gray-700 rounded-full h-2.5 mb-3">
<div
className="bg-blue-500 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, percentage)}%` }}
/>
</div>
{/* Upload Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div>
<span className="text-gray-400">Progress:</span>
<span className="ml-2 font-medium">
{FormatBytes(bytesUploaded)} / {FormatBytes(totalBytes)}
</span>
</div>
<div>
<span className="text-gray-400">Speed:</span>
<span className="ml-2 font-medium text-green-400">
{formatSpeed(averageSpeed)}
</span>
</div>
<div>
<span className="text-gray-400">ETA:</span>
<span className="ml-2 font-medium">
{formatTime(estimatedTimeRemaining)}
</span>
</div>
</div>
</div>
);
}

View File

@ -43,3 +43,6 @@ export function FormatBytes(b: number, f?: number) {
if (b >= kiB) return (b / kiB).toFixed(f) + " KiB"; if (b >= kiB) return (b / kiB).toFixed(f) + " KiB";
return b.toFixed(f) + " B"; return b.toFixed(f) + " B";
} }
export const ServerUrl =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;

View File

@ -1,10 +1,16 @@
import { EventPublisher, Nip7Signer } from "@snort/system"; import { EventPublisher, Nip7Signer } from "@snort/system";
import { useMemo } from "react";
import useLogin from "./login"; import useLogin from "./login";
export default function usePublisher() { export default function usePublisher() {
const login = useLogin(); const login = useLogin();
return useMemo(() => {
switch (login?.type) { switch (login?.type) {
case "nip7": case "nip7":
return new EventPublisher(new Nip7Signer(), login.pubkey); return new EventPublisher(new Nip7Signer(), login.pubkey);
default:
return undefined;
} }
}, [login?.type, login?.pubkey]);
} }

View File

@ -0,0 +1,25 @@
import useLogin from "./login";
import { useRequestBuilder } from "@snort/system-react";
import { EventKind, RequestBuilder } from "@snort/system";
import { dedupe, removeUndefined, sanitizeRelayUrl } from "@snort/shared";
import { ServerUrl } from "../const";
const DefaultMediaServers = ["https://blossom.band/", "https://blossom.primal.net", ServerUrl]
export function useBlossomServers() {
const login = useLogin();
const rb = new RequestBuilder("media-servers");
if (login?.pubkey) {
rb.withFilter()
.kinds([10_063 as EventKind])
.authors([login.pubkey]);
}
const req = useRequestBuilder(rb);
const servers = req === undefined ? undefined :
req
.flatMap((e) => e.tags.filter(t => t[0] === "server")
.map((t) => t[1]));
return dedupe(removeUndefined([...DefaultMediaServers, ...(servers ?? [])].map(sanitizeRelayUrl)));
}

View File

@ -4,9 +4,49 @@
html, html,
body { body {
@apply bg-black text-white; @apply bg-gray-900 text-gray-100 font-sans;
}
[data-theme="light"] {
@apply bg-gray-50 text-gray-900;
} }
hr { hr {
@apply border-neutral-500; @apply border-gray-700;
}
[data-theme="light"] hr {
@apply border-gray-200;
}
.card {
@apply bg-gray-800 rounded-lg shadow-sm border border-gray-700 p-6;
}
[data-theme="light"] .card {
@apply bg-white border-gray-200;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-gray-200 px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
[data-theme="light"] .btn-secondary {
@apply bg-gray-100 hover:bg-gray-200 text-gray-700;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200;
}
.input {
@apply border border-gray-600 bg-gray-700 text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
[data-theme="light"] .input {
@apply border-gray-300 bg-white text-gray-900;
} }

View File

@ -13,6 +13,11 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
this.notifyChange(); this.notifyChange();
} }
logout() {
this.#session = undefined;
this.notifyChange();
}
takeSnapshot(): LoginSession | undefined { takeSnapshot(): LoginSession | undefined {
return this.#session ? { ...this.#session } : undefined; return this.#session ? { ...this.#session } : undefined;
} }

View File

@ -23,7 +23,9 @@ export interface Report {
export interface PaymentInfo { export interface PaymentInfo {
unit: string; unit: string;
interval: string; interval: {
[key: string]: number;
};
cost: { cost: {
currency: string; currency: string;
amount: number; amount: number;

View File

@ -1,6 +1,7 @@
import { base64, bytesToString } from "@scure/base"; import { base64, bytesToString } from "@scure/base";
import { throwIfOffline, unixNow } from "@snort/shared"; import { throwIfOffline, unixNow } from "@snort/shared";
import { EventKind, EventPublisher } from "@snort/system"; import { EventKind, EventPublisher } from "@snort/system";
import { UploadProgressCallback, uploadWithProgress } from "./progress";
export interface BlobDescriptor { export interface BlobDescriptor {
url?: string; url?: string;
@ -18,43 +19,53 @@ export class Blossom {
this.url = new URL(this.url).toString(); this.url = new URL(this.url).toString();
} }
async upload(file: File) { async #handleError(rsp: Response) {
const hash = await window.crypto.subtle.digest( const reason = rsp.headers.get("X-Reason") || rsp.headers.get("x-reason");
"SHA-256", if (reason) {
await file.arrayBuffer(), throw new Error(reason);
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("upload", "PUT", "upload", file, tags);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else { } else {
const text = await rsp.text(); const text = await rsp.text();
throw new Error(text); throw new Error(text);
} }
} }
async media(file: File) { async upload(file: File, onProgress?: UploadProgressCallback): Promise<BlobDescriptor> {
const hash = await window.crypto.subtle.digest( const hash = await window.crypto.subtle.digest(
"SHA-256", "SHA-256",
await file.arrayBuffer(), await file.arrayBuffer(),
); );
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]]; const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("media", "PUT", "media", file, tags); const rsp = await this.#req("upload", "PUT", "upload", file, tags, undefined, onProgress);
if (rsp.ok) { if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor; return (await rsp.json()) as BlobDescriptor;
} else { } else {
const text = await rsp.text(); await this.#handleError(rsp);
throw new Error(text); throw new Error("Should not reach here");
} }
} }
async mirror(url: string) { async media(file: File, onProgress?: UploadProgressCallback): Promise<BlobDescriptor> {
const hash = await window.crypto.subtle.digest(
"SHA-256",
await file.arrayBuffer(),
);
const tags = [["x", bytesToString("hex", new Uint8Array(hash))]];
const rsp = await this.#req("media", "PUT", "media", file, tags, undefined, onProgress);
if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor;
} else {
await this.#handleError(rsp);
throw new Error("Should not reach here");
}
}
async mirror(url: string): Promise<BlobDescriptor> {
const rsp = await this.#req( const rsp = await this.#req(
"mirror", "mirror",
"PUT", "PUT",
"mirror", "upload",
JSON.stringify({ url }), JSON.stringify({ url }),
undefined, undefined,
{ {
@ -64,28 +75,28 @@ export class Blossom {
if (rsp.ok) { if (rsp.ok) {
return (await rsp.json()) as BlobDescriptor; return (await rsp.json()) as BlobDescriptor;
} else { } else {
const text = await rsp.text(); await this.#handleError(rsp);
throw new Error(text); throw new Error("Should not reach here");
} }
} }
async list(pk: string) { async list(pk: string): Promise<Array<BlobDescriptor>> {
const rsp = await this.#req(`list/${pk}`, "GET", "list"); const rsp = await this.#req(`list/${pk}`, "GET", "list");
if (rsp.ok) { if (rsp.ok) {
return (await rsp.json()) as Array<BlobDescriptor>; return (await rsp.json()) as Array<BlobDescriptor>;
} else { } else {
const text = await rsp.text(); await this.#handleError(rsp);
throw new Error(text); throw new Error("Should not reach here");
} }
} }
async delete(id: string) { async delete(id: string): Promise<void> {
const tags = [["x", id]]; const tags = [["x", id]];
const rsp = await this.#req(id, "DELETE", "delete", undefined, tags); const rsp = await this.#req(id, "DELETE", "delete", undefined, tags);
if (!rsp.ok) { if (!rsp.ok) {
const text = await rsp.text(); await this.#handleError(rsp);
throw new Error(text); throw new Error("Should not reach here");
} }
} }
@ -96,6 +107,7 @@ export class Blossom {
body?: BodyInit, body?: BodyInit,
tags?: Array<Array<string>>, tags?: Array<Array<string>>,
headers?: Record<string, string>, headers?: Record<string, string>,
onProgress?: UploadProgressCallback,
) { ) {
throwIfOffline(); throwIfOffline();
@ -116,14 +128,22 @@ export class Blossom {
)}`; )}`;
}; };
return await fetch(url, { const requestHeaders = {
method,
body,
headers: {
...headers, ...headers,
accept: "application/json", accept: "application/json",
authorization: await auth(url, method), authorization: await auth(url, method),
}, };
// Use progress-enabled upload for PUT requests with body
if (method === "PUT" && body && onProgress) {
return await uploadWithProgress(url, method, body, requestHeaders, onProgress);
}
// Fall back to regular fetch for other requests
return await fetch(url, {
method,
body,
headers: requestHeaders,
}); });
} }
} }

View File

@ -3,6 +3,7 @@ export async function openFile(): Promise<File | undefined> {
const elm = document.createElement("input"); const elm = document.createElement("input");
let lock = false; let lock = false;
elm.type = "file"; elm.type = "file";
elm.multiple = true; // Allow multiple file selection
const handleInput = (e: Event) => { const handleInput = (e: Event) => {
lock = true; lock = true;
const elm = e.target as HTMLInputElement; const elm = e.target as HTMLInputElement;
@ -28,3 +29,35 @@ export async function openFile(): Promise<File | undefined> {
); );
}); });
} }
export async function openFiles(): Promise<FileList | undefined> {
return new Promise((resolve) => {
const elm = document.createElement("input");
let lock = false;
elm.type = "file";
elm.multiple = true;
const handleInput = (e: Event) => {
lock = true;
const elm = e.target as HTMLInputElement;
if ((elm.files?.length ?? 0) > 0) {
resolve(elm.files!);
} else {
resolve(undefined);
}
};
elm.onchange = (e) => handleInput(e);
elm.click();
window.addEventListener(
"focus",
() => {
setTimeout(() => {
if (!lock) {
resolve(undefined);
}
}, 300);
},
{ once: true },
);
});
}

View File

@ -1,6 +1,7 @@
import { base64 } from "@scure/base"; import { base64 } from "@scure/base";
import { throwIfOffline } from "@snort/shared"; import { throwIfOffline } from "@snort/shared";
import { EventKind, EventPublisher, NostrEvent } from "@snort/system"; import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
import { UploadProgressCallback, uploadWithProgress } from "./progress";
export class Nip96 { export class Nip96 {
#info?: Nip96Info; #info?: Nip96Info;
@ -28,14 +29,14 @@ export class Nip96 {
return data; return data;
} }
async upload(file: File) { async upload(file: File, onProgress?: UploadProgressCallback) {
const fd = new FormData(); const fd = new FormData();
fd.append("size", file.size.toString()); fd.append("size", file.size.toString());
fd.append("caption", file.name); fd.append("caption", file.name);
fd.append("content_type", file.type); fd.append("content_type", file.type);
fd.append("file", file); fd.append("file", file);
const rsp = await this.#req("", "POST", fd); const rsp = await this.#req("", "POST", fd, onProgress);
const data = await this.#handleResponse<Nip96Result>(rsp); const data = await this.#handleResponse<Nip96Result>(rsp);
if (data.status !== "success") { if (data.status !== "success") {
throw new Error(data.message); throw new Error(data.message);
@ -57,7 +58,7 @@ export class Nip96 {
} }
} }
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) { async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit, onProgress?: UploadProgressCallback) {
throwIfOffline(); throwIfOffline();
const auth = async (url: string, method: string) => { const auth = async (url: string, method: string) => {
const auth = await this.publisher.generic((eb) => { const auth = await this.publisher.generic((eb) => {
@ -77,13 +78,22 @@ export class Nip96 {
u = `${this.url}${u.slice(1)}`; u = `${this.url}${u.slice(1)}`;
} }
u += path; u += path;
const requestHeaders = {
accept: "application/json",
authorization: await auth(u, method),
};
// Use progress-enabled upload for POST requests with FormData
if (method === "POST" && body && onProgress) {
return await uploadWithProgress(u, method, body, requestHeaders, onProgress);
}
// Fall back to regular fetch for other requests
return await fetch(u, { return await fetch(u, {
method, method,
body, body,
headers: { headers: requestHeaders,
accept: "application/json",
authorization: await auth(u, method),
},
}); });
} }
} }

View File

@ -0,0 +1,184 @@
// Upload progress tracking types and utilities
export interface UploadProgress {
percentage: number;
bytesUploaded: number;
totalBytes: number;
averageSpeed: number; // bytes per second
estimatedTimeRemaining: number; // seconds
startTime: number;
}
export interface UploadProgressCallback {
(progress: UploadProgress): void;
}
export class ProgressTracker {
private startTime: number;
private lastUpdateTime: number;
private bytesUploaded: number = 0;
private totalBytes: number;
private speedSamples: number[] = [];
private maxSamples = 10; // Keep last 10 speed samples for averaging
constructor(totalBytes: number) {
this.totalBytes = totalBytes;
this.startTime = Date.now();
this.lastUpdateTime = this.startTime;
}
update(bytesUploaded: number): UploadProgress {
const now = Date.now();
const timeDiff = now - this.lastUpdateTime;
// Calculate instantaneous speed
if (timeDiff > 0) {
const bytesDiff = bytesUploaded - this.bytesUploaded;
const instantSpeed = (bytesDiff / timeDiff) * 1000; // bytes per second
// Keep a rolling average of speed samples
this.speedSamples.push(instantSpeed);
if (this.speedSamples.length > this.maxSamples) {
this.speedSamples.shift();
}
}
this.bytesUploaded = bytesUploaded;
this.lastUpdateTime = now;
// Calculate average speed
const averageSpeed = this.speedSamples.length > 0
? this.speedSamples.reduce((sum, speed) => sum + speed, 0) / this.speedSamples.length
: 0;
// Calculate estimated time remaining
const remainingBytes = this.totalBytes - bytesUploaded;
const estimatedTimeRemaining = averageSpeed > 0 ? remainingBytes / averageSpeed : 0;
return {
percentage: (bytesUploaded / this.totalBytes) * 100,
bytesUploaded,
totalBytes: this.totalBytes,
averageSpeed,
estimatedTimeRemaining,
startTime: this.startTime,
};
}
}
// Utility function to format speed for display
export function formatSpeed(bytesPerSecond: number): string {
if (bytesPerSecond === 0) return "0 B/s";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let value = bytesPerSecond;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
// Utility function to format time for display
export function formatTime(seconds: number): string {
if (seconds === 0 || !isFinite(seconds)) return "--";
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
} else {
return `${remainingSeconds}s`;
}
}
// XMLHttpRequest wrapper with progress tracking
export function uploadWithProgress(
url: string,
method: string,
body: BodyInit | null,
headers: Record<string, string>,
onProgress?: UploadProgressCallback,
): Promise<Response> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Determine total size
let totalSize = 0;
if (body instanceof File) {
totalSize = body.size;
} else if (body instanceof FormData) {
// For FormData, we need to estimate size
const formData = body as FormData;
for (const [, value] of formData.entries()) {
if (value instanceof File) {
totalSize += value.size;
} else if (typeof value === 'string') {
totalSize += new Blob([value]).size;
}
}
}
const tracker = new ProgressTracker(totalSize);
// Set up progress tracking
if (onProgress && totalSize > 0) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = tracker.update(event.loaded);
onProgress(progress);
}
});
}
// Set up response handling
xhr.addEventListener('load', () => {
const response = new Response(xhr.response, {
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders()),
});
resolve(response);
});
xhr.addEventListener('error', () => {
reject(new Error('Network error'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'));
});
// Configure request
xhr.open(method, url);
// Set headers
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
// Send request
xhr.send(body as XMLHttpRequestBodyInit | Document | null);
});
}
// Helper function to parse response headers
function parseHeaders(headerString: string): Headers {
const headers = new Headers();
const lines = headerString.trim().split('\r\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers.append(name, value);
}
}
return headers;
}

248
ui_src/src/views/admin.tsx Normal file
View File

@ -0,0 +1,248 @@
import { useEffect, useState, useCallback } from "react";
import { Navigate } from "react-router-dom";
import Button from "../components/button";
import FileList from "./files";
import ReportList from "./reports";
import { Blossom } from "../upload/blossom";
import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher";
import { Nip96FileList } from "../upload/nip96";
import { AdminSelf, Route96, Report } from "../upload/admin";
export default function Admin() {
const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>();
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
const [reports, setReports] = useState<Report[]>();
const [reportPages, setReportPages] = useState<number>();
const [reportPage, setReportPage] = useState(0);
const [adminListedPage, setAdminListedPage] = useState(0);
const [mimeFilter, setMimeFilter] = useState<string>();
const [loading, setLoading] = useState(true);
const login = useLogin();
const pub = usePublisher();
const url =
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`;
const listAllUploads = useCallback(
async (n: number) => {
if (!pub) return;
try {
setError(undefined);
const uploader = new Route96(url, pub);
const result = await uploader.listFiles(n, 50, mimeFilter);
setAdminListedFiles(result);
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List files failed");
}
}
},
[pub, url, mimeFilter],
);
const listReports = useCallback(
async (n: number) => {
if (!pub) return;
try {
setError(undefined);
const route96 = new Route96(url, pub);
const result = await route96.listReports(n, 10);
setReports(result.files);
setReportPages(Math.ceil(result.total / result.count));
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "List reports failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List reports failed");
}
}
},
[pub, url],
);
async function acknowledgeReport(reportId: number) {
if (!pub) return;
try {
setError(undefined);
const route96 = new Route96(url, pub);
await route96.acknowledgeReport(reportId);
await listReports(reportPage);
} catch (e) {
if (e instanceof Error) {
setError(
e.message.length > 0 ? e.message : "Acknowledge report failed",
);
} else if (typeof e === "string") {
setError(e);
} else {
setError("Acknowledge report failed");
}
}
}
async function deleteFile(id: string) {
if (!pub) return;
try {
setError(undefined);
const uploader = new Blossom(url, pub);
await uploader.delete(id);
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List files failed");
}
}
}
useEffect(() => {
if (pub && !self) {
const r96 = new Route96(url, pub);
r96
.getSelf()
.then((v) => {
setSelf(v.data);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}
}, [pub, self, url]);
useEffect(() => {
if (pub && self?.is_admin) {
listAllUploads(adminListedPage);
}
}, [adminListedPage, pub, self?.is_admin, listAllUploads]);
useEffect(() => {
if (pub && self?.is_admin) {
listReports(reportPage);
}
}, [reportPage, pub, self?.is_admin, listReports]);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-lg text-gray-400">Loading...</div>
</div>
);
}
if (!login) {
return (
<div className="card max-w-md mx-auto text-center">
<h2 className="text-xl font-semibold mb-4">Authentication Required</h2>
<p className="text-gray-400">
Please log in to access the admin panel.
</p>
</div>
);
}
if (!self?.is_admin) {
return <Navigate to="/" replace />;
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-100">Admin Panel</h1>
</div>
{error && (
<div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg">
{error}
</div>
)}
<div className="grid gap-8 lg:grid-cols-2">
<div className="card">
<h2 className="text-xl font-semibold mb-6">File Management</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Filter by MIME type
</label>
<select
className="input w-full"
value={mimeFilter || ""}
onChange={(e) => setMimeFilter(e.target.value || undefined)}
>
<option value="">All Files</option>
<option value="image/webp">WebP Images</option>
<option value="image/jpeg">JPEG Images</option>
<option value="image/jpg">JPG Images</option>
<option value="image/png">PNG Images</option>
<option value="image/gif">GIF Images</option>
<option value="video/mp4">MP4 Videos</option>
<option value="video/mov">MOV Videos</option>
</select>
</div>
<Button
onClick={() => listAllUploads(0)}
className="btn-primary w-full"
>
Load All Files
</Button>
</div>
</div>
<div className="card">
<h2 className="text-xl font-semibold mb-6">Reports Management</h2>
<Button onClick={() => listReports(0)} className="btn-primary w-full">
Load Reports
</Button>
</div>
</div>
{adminListedFiles && (
<div className="card">
<h2 className="text-xl font-semibold mb-6">All Files</h2>
<FileList
files={adminListedFiles.files}
pages={Math.ceil(adminListedFiles.total / adminListedFiles.count)}
page={adminListedFiles.page}
onPage={(x) => setAdminListedPage(x)}
onDelete={async (x) => {
await deleteFile(x);
await listAllUploads(adminListedPage);
}}
/>
</div>
)}
{reports && (
<div className="card">
<h2 className="text-xl font-semibold mb-6">Reports</h2>
<ReportList
reports={reports}
pages={reportPages}
page={reportPage}
onPage={(x) => setReportPage(x)}
onAcknowledge={acknowledgeReport}
onDeleteFile={async (fileId) => {
await deleteFile(fileId);
await listReports(reportPage);
}}
/>
</div>
)}
</div>
);
}

View File

@ -28,7 +28,7 @@ export default function FileList({
}) { }) {
const [viewType, setViewType] = useState<"grid" | "list">("grid"); const [viewType, setViewType] = useState<"grid" | "list">("grid");
if (files.length === 0) { if (files.length === 0) {
return <b>No Files</b>; return <b className="text-gray-400">No Files</b>;
} }
function renderInner(f: FileInfo) { function renderInner(f: FileInfo) {
@ -77,19 +77,22 @@ export default function FileList({
for (let x = start; x < n; x++) { for (let x = start; x < n; x++) {
ret.push( ret.push(
<div <button
key={x}
onClick={() => onPage?.(x)} onClick={() => onPage?.(x)}
className={classNames( className={classNames(
"bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold", "px-3 py-2 text-sm font-medium border transition-colors",
{ {
"rounded-l-md": x === start, "rounded-l-md": x === start,
"rounded-r-md": x + 1 === n, "rounded-r-md": x + 1 === n,
"bg-neutral-400": page === x, "bg-blue-600 text-white border-blue-600": page === x,
"bg-white text-gray-700 border-gray-300 hover:bg-gray-50":
page !== x,
}, },
)} )}
> >
{x + 1} {x + 1}
</div>, </button>,
); );
} }
@ -98,49 +101,56 @@ export default function FileList({
function showGrid() { function showGrid() {
return ( return (
<div className="grid gap-2 grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10"> <div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8">
{files.map((a) => { {files.map((a) => {
const info = getInfo(a); const info = getInfo(a);
return ( return (
<div <div
key={info.id} key={info.id}
className="relative rounded-md aspect-square overflow-hidden bg-neutral-900" className="group relative rounded-lg aspect-square overflow-hidden bg-gray-100 border border-gray-200 hover:shadow-md transition-shadow"
> >
<div className="absolute flex flex-col items-center justify-center w-full h-full text-wrap text-sm break-all text-center opacity-0 hover:opacity-100 hover:bg-black/80"> <div className="absolute inset-0 flex flex-col items-center justify-center p-2 text-xs text-center opacity-0 group-hover:opacity-100 bg-black/75 text-white transition-opacity">
<div> <div className="font-medium mb-1">
{(info.name?.length ?? 0) === 0 {(info.name?.length ?? 0) === 0
? "Untitled" ? "Untitled"
: info.name!.length > 20 : info.name!.length > 20
? `${info.name?.substring(0, 10)}...${info.name?.substring(info.name.length - 10)}` ? `${info.name?.substring(0, 10)}...${info.name?.substring(info.name.length - 10)}`
: info.name} : info.name}
</div> </div>
<div> <div className="text-gray-300 mb-1">
{info.size && !isNaN(info.size) {info.size && !isNaN(info.size)
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</div> </div>
<div>{info.type}</div> <div className="text-gray-300 mb-2">{info.type}</div>
<div className="flex gap-2"> <div className="flex gap-2">
<a href={info.url} className="underline" target="_blank"> <a
Link href={info.url}
className="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-xs"
target="_blank"
>
View
</a> </a>
{onDelete && ( {onDelete && (
<a <button
href="#"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onDelete?.(info.id); onDelete?.(info.id);
}} }}
className="underline" className="bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-xs"
> >
Delete Delete
</a> </button>
)} )}
</div> </div>
{info.uploader && {info.uploader &&
info.uploader.map((a) => ( info.uploader.map((a, idx) => (
<Profile link={NostrLink.publicKey(a)} size={20} /> <Profile
key={idx}
link={NostrLink.publicKey(a)}
size={20}
/>
))} ))}
</div> </div>
{renderInner(info)} {renderInner(info)}
@ -153,73 +163,83 @@ export default function FileList({
function showList() { function showList() {
return ( return (
<table className="table-auto text-sm"> <div className="overflow-x-auto">
<thead> <table className="min-w-full bg-white border border-gray-200 rounded-lg">
<thead className="bg-gray-50">
<tr> <tr>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Preview Preview
</th> </th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Name Name
</th> </th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Type Type
</th> </th>
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Size Size
</th> </th>
{files.some((i) => "uploader" in i) && ( {files.some((i) => "uploader" in i) && (
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Uploader Uploader
</th> </th>
)} )}
<th className="border border-neutral-400 bg-neutral-500 py-1 px-2"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b border-gray-200">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-gray-200">
{files.map((a) => { {files.map((a) => {
const info = getInfo(a); const info = getInfo(a);
return ( return (
<tr key={info.id}> <tr key={info.id} className="hover:bg-gray-50">
<td className="border border-neutral-500 py-1 px-2 w-8 h-8"> <td className="px-4 py-3 w-16">
<div className="w-12 h-12 bg-gray-100 rounded overflow-hidden">
{renderInner(info)} {renderInner(info)}
</div>
</td> </td>
<td className="border border-neutral-500 py-1 px-2 break-all"> <td className="px-4 py-3 text-sm text-gray-900 break-all max-w-xs">
{(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name} {(info.name?.length ?? 0) === 0 ? "<Untitled>" : info.name}
</td> </td>
<td className="border border-neutral-500 py-1 px-2 break-all"> <td className="px-4 py-3 text-sm text-gray-500">
{info.type} {info.type}
</td> </td>
<td className="border border-neutral-500 py-1 px-2"> <td className="px-4 py-3 text-sm text-gray-500">
{info.size && !isNaN(info.size) {info.size && !isNaN(info.size)
? FormatBytes(info.size, 2) ? FormatBytes(info.size, 2)
: ""} : ""}
</td> </td>
{info.uploader && ( {info.uploader && (
<td className="border border-neutral-500 py-1 px-2"> <td className="px-4 py-3">
{info.uploader.map((a) => ( {info.uploader.map((a, idx) => (
<Profile link={NostrLink.publicKey(a)} size={20} /> <Profile
key={idx}
link={NostrLink.publicKey(a)}
size={20}
/>
))} ))}
</td> </td>
)} )}
<td className="border border-neutral-500 py-1 px-2"> <td className="px-4 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<a href={info.url} className="underline" target="_blank"> <a
Link href={info.url}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs"
target="_blank"
>
View
</a> </a>
{onDelete && ( {onDelete && (
<a <button
href="#"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onDelete?.(info.id); onDelete?.(info.id);
}} }}
className="underline" className="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded text-xs"
> >
Delete Delete
</a> </button>
)} )}
</div> </div>
</td> </td>
@ -228,31 +248,46 @@ export default function FileList({
})} })}
</tbody> </tbody>
</table> </table>
</div>
); );
} }
return ( return (
<> <div className="space-y-4">
<div className="flex"> <div className="flex justify-between items-center">
<div <div className="flex rounded-lg border border-gray-300 overflow-hidden">
<button
onClick={() => setViewType("grid")} onClick={() => setViewType("grid")}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-l-md ${viewType === "grid" ? "bg-neutral-500" : ""}`} className={`px-4 py-2 text-sm font-medium transition-colors ${
viewType === "grid"
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
> >
Grid Grid
</div> </button>
<div <button
onClick={() => setViewType("list")} onClick={() => setViewType("list")}
className={`bg-neutral-700 hover:bg-neutral-600 min-w-20 text-center cursor-pointer font-bold rounded-r-md ${viewType === "list" ? "bg-neutral-500" : ""}`} className={`px-4 py-2 text-sm font-medium transition-colors border-l border-gray-300 ${
viewType === "list"
? "bg-blue-600 text-white"
: "bg-white text-gray-700 hover:bg-gray-50"
}`}
> >
List List
</button>
</div> </div>
</div> </div>
{viewType === "grid" ? showGrid() : showList()} {viewType === "grid" ? showGrid() : showList()}
{pages !== undefined && (
<> {pages !== undefined && pages > 1 && (
<div className="flex flex-wrap">{pageButtons(page ?? 0, pages)}</div> <div className="flex justify-center">
</> <div className="flex rounded-lg border border-gray-300 overflow-hidden">
{pageButtons(page ?? 0, pages)}
</div>
</div>
)} )}
</> </div>
); );
} }

View File

@ -1,11 +1,22 @@
import { Nip7Signer, NostrLink } from "@snort/system"; import { Nip7Signer, NostrLink } from "@snort/system";
import { Link, useLocation } from "react-router-dom";
import { useEffect, useState } from "react";
import Button from "../components/button"; import Button from "../components/button";
import Profile from "../components/profile"; import Profile from "../components/profile";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher";
import { Login } from "../login"; import { Login } from "../login";
import { AdminSelf, Route96 } from "../upload/admin";
export default function Header() { export default function Header() {
const login = useLogin(); const login = useLogin();
const pub = usePublisher();
const location = useLocation();
const [self, setSelf] = useState<AdminSelf>();
const url =
import.meta.env.VITE_API_URL ||
`${window.location.protocol}//${window.location.host}`;
async function tryLogin() { async function tryLogin() {
try { try {
@ -19,14 +30,72 @@ export default function Header() {
//ignore //ignore
} }
} }
useEffect(() => {
if (pub && !self) {
const r96 = new Route96(url, pub);
r96
.getSelf()
.then((v) => setSelf(v.data))
.catch(() => {});
}
}, [pub, self, url]);
return ( return (
<div className="flex justify-between items-center"> <header className="border-b border-gray-700 bg-gray-800 w-full">
<div className="text-xl font-bold">route96</div> <div className="px-4 flex justify-between items-center py-4">
<div className="flex items-center space-x-8">
<Link to="/">
<div className="text-2xl font-bold text-gray-100 hover:text-blue-400 transition-colors">
route96
</div>
</Link>
<nav className="flex space-x-6">
<Link
to="/"
className={`text-sm font-medium transition-colors ${
location.pathname === "/"
? "text-blue-400 border-b-2 border-blue-400 pb-1"
: "text-gray-300 hover:text-gray-100"
}`}
>
Upload
</Link>
{self?.is_admin && (
<Link
to="/admin"
className={`text-sm font-medium transition-colors ${
location.pathname === "/admin"
? "text-blue-400 border-b-2 border-blue-400 pb-1"
: "text-gray-300 hover:text-gray-100"
}`}
>
Admin
</Link>
)}
</nav>
</div>
<div className="flex items-center space-x-4">
{login ? ( {login ? (
<div className="flex items-center space-x-3">
<Profile link={NostrLink.publicKey(login.pubkey)} /> <Profile link={NostrLink.publicKey(login.pubkey)} />
<Button
onClick={() => Login.logout()}
className="btn-secondary text-sm"
>
Logout
</Button>
</div>
) : ( ) : (
<Button onClick={tryLogin}>Login</Button> <Button onClick={tryLogin} className="btn-primary">
Login
</Button>
)} )}
</div> </div>
</div>
</header>
); );
} }

View File

@ -75,12 +75,24 @@ export default function ReportList({
<table className="w-full border-collapse border border-neutral-500"> <table className="w-full border-collapse border border-neutral-500">
<thead> <thead>
<tr className="bg-neutral-700"> <tr className="bg-neutral-700">
<th className="border border-neutral-500 py-2 px-4 text-left">Report ID</th> <th className="border border-neutral-500 py-2 px-4 text-left">
<th className="border border-neutral-500 py-2 px-4 text-left">File ID</th> Report ID
<th className="border border-neutral-500 py-2 px-4 text-left">Reporter</th> </th>
<th className="border border-neutral-500 py-2 px-4 text-left">Reason</th> <th className="border border-neutral-500 py-2 px-4 text-left">
<th className="border border-neutral-500 py-2 px-4 text-left">Created</th> File ID
<th className="border border-neutral-500 py-2 px-4 text-left">Actions</th> </th>
<th className="border border-neutral-500 py-2 px-4 text-left">
Reporter
</th>
<th className="border border-neutral-500 py-2 px-4 text-left">
Reason
</th>
<th className="border border-neutral-500 py-2 px-4 text-left">
Created
</th>
<th className="border border-neutral-500 py-2 px-4 text-left">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -90,13 +102,18 @@ export default function ReportList({
return ( return (
<tr key={report.id} className="hover:bg-neutral-700"> <tr key={report.id} className="hover:bg-neutral-700">
<td className="border border-neutral-500 py-2 px-4">{report.id}</td> <td className="border border-neutral-500 py-2 px-4">
{report.id}
</td>
<td className="border border-neutral-500 py-2 px-4 font-mono text-sm"> <td className="border border-neutral-500 py-2 px-4 font-mono text-sm">
{report.file_id.substring(0, 12)}... {report.file_id.substring(0, 12)}...
</td> </td>
<td className="border border-neutral-500 py-2 px-4"> <td className="border border-neutral-500 py-2 px-4">
{reporterPubkey ? ( {reporterPubkey ? (
<Profile link={NostrLink.publicKey(reporterPubkey)} size={20} /> <Profile
link={NostrLink.publicKey(reporterPubkey)}
size={20}
/>
) : ( ) : (
"Unknown" "Unknown"
)} )}

View File

@ -1,298 +1,365 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import Button from "../components/button"; import Button from "../components/button";
import FileList from "./files"; import FileList from "./files";
import ReportList from "./reports";
import PaymentFlow from "../components/payment"; import PaymentFlow from "../components/payment";
import { openFile } from "../upload"; import ProgressBar from "../components/progress-bar";
import { Blossom } from "../upload/blossom"; import MirrorSuggestions from "../components/mirror-suggestions";
import { useBlossomServers } from "../hooks/use-blossom-servers";
import { openFiles } from "../upload";
import { Blossom, BlobDescriptor } from "../upload/blossom";
import useLogin from "../hooks/login"; import useLogin from "../hooks/login";
import usePublisher from "../hooks/publisher"; import usePublisher from "../hooks/publisher";
import { Nip96, Nip96FileList } from "../upload/nip96"; import { Nip96, Nip96FileList } from "../upload/nip96";
import { AdminSelf, Route96, Report } from "../upload/admin"; import { AdminSelf, Route96 } from "../upload/admin";
import { FormatBytes } from "../const"; import { FormatBytes, ServerUrl } from "../const";
import { UploadProgress } from "../upload/progress";
export default function Upload() { export default function Upload() {
const [type, setType] = useState<"blossom" | "nip96">("blossom"); const [stripMetadata, setStripMetadata] = useState(true);
const [noCompress, setNoCompress] = useState(false);
const [toUpload, setToUpload] = useState<File>();
const [self, setSelf] = useState<AdminSelf>(); const [self, setSelf] = useState<AdminSelf>();
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [results, setResults] = useState<Array<object>>([]); const [results, setResults] = useState<Array<BlobDescriptor>>([]);
const [listedFiles, setListedFiles] = useState<Nip96FileList>(); const [listedFiles, setListedFiles] = useState<Nip96FileList>();
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
const [reports, setReports] = useState<Report[]>();
const [reportPages, setReportPages] = useState<number>();
const [reportPage, setReportPage] = useState(0);
const [listedPage, setListedPage] = useState(0); const [listedPage, setListedPage] = useState(0);
const [adminListedPage, setAdminListedPage] = useState(0);
const [mimeFilter, setMimeFilter] = useState<string>();
const [showPaymentFlow, setShowPaymentFlow] = useState(false); const [showPaymentFlow, setShowPaymentFlow] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<UploadProgress>();
const blossomServers = useBlossomServers();
const login = useLogin(); const login = useLogin();
const pub = usePublisher(); const pub = usePublisher();
const url = // Check if file should have compression enabled by default
import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`; const shouldCompress = (file: File) => {
async function doUpload() { return file.type.startsWith('video/') || file.type.startsWith('image/');
};
async function doUpload(file: File) {
if (!pub) return; if (!pub) return;
if (!toUpload) return; if (!file) return;
if (isUploading) return; // Prevent multiple uploads
try { try {
setError(undefined); setError(undefined);
if (type === "blossom") { setIsUploading(true);
const uploader = new Blossom(url, pub); setUploadProgress(undefined);
const result = noCompress
? await uploader.upload(toUpload) const onProgress = (progress: UploadProgress) => {
: await uploader.media(toUpload); setUploadProgress(progress);
};
const uploader = new Blossom(ServerUrl, pub);
// Use compression for video and image files when metadata stripping is enabled
const useCompression = shouldCompress(file) && stripMetadata;
const result = useCompression
? await uploader.media(file, onProgress)
: await uploader.upload(file, onProgress);
setResults((s) => [...s, result]); setResults((s) => [...s, result]);
}
if (type === "nip96") {
const uploader = new Nip96(url, pub);
await uploader.loadInfo();
const result = await uploader.upload(toUpload);
setResults((s) => [...s, result]);
}
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed"); setError(e.message || "Upload failed - no error details provided");
} else if (typeof e === "string") { } else if (typeof e === "string") {
setError(e); setError(e);
} else { } else {
setError("Upload failed"); setError("Upload failed");
} }
} finally {
setIsUploading(false);
setUploadProgress(undefined);
} }
} }
async function listUploads(n: number) { async function handleFileSelection() {
if (isUploading) return;
try {
const files = await openFiles();
if (!files || files.length === 0) return;
// Start uploading each file immediately
for (let i = 0; i < files.length; i++) {
const file = files[i];
await doUpload(file);
}
} catch (e) {
if (e instanceof Error) {
setError(e.message || "File selection failed");
} else {
setError("File selection failed");
}
}
}
const listUploads = useCallback(
async (n: number) => {
if (!pub) return; if (!pub) return;
try { try {
setError(undefined); setError(undefined);
const uploader = new Nip96(url, pub); const uploader = new Nip96(ServerUrl, pub);
await uploader.loadInfo(); await uploader.loadInfo();
const result = await uploader.listFiles(n, 50); const result = await uploader.listFiles(n, 50);
setListedFiles(result); setListedFiles(result);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed"); setError(
e.message || "List files failed - no error details provided",
);
} else if (typeof e === "string") { } else if (typeof e === "string") {
setError(e); setError(e);
} else { } else {
setError("List files failed"); setError("List files failed");
} }
} }
} },
[pub],
async function listAllUploads(n: number) { );
if (!pub) return;
try {
setError(undefined);
const uploader = new Route96(url, pub);
const result = await uploader.listFiles(n, 50, mimeFilter);
setAdminListedFiles(result);
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List files failed");
}
}
}
async function listReports(n: number) {
if (!pub) return;
try {
setError(undefined);
const route96 = new Route96(url, pub);
const result = await route96.listReports(n, 10);
setReports(result.files);
setReportPages(Math.ceil(result.total / result.count));
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "List reports failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("List reports failed");
}
}
}
async function acknowledgeReport(reportId: number) {
if (!pub) return;
try {
setError(undefined);
const route96 = new Route96(url, pub);
await route96.acknowledgeReport(reportId);
await listReports(reportPage); // Refresh the list
} catch (e) {
if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Acknowledge report failed");
} else if (typeof e === "string") {
setError(e);
} else {
setError("Acknowledge report failed");
}
}
}
async function deleteFile(id: string) { async function deleteFile(id: string) {
if (!pub) return; if (!pub) return;
try { try {
setError(undefined); setError(undefined);
const uploader = new Blossom(url, pub); const uploader = new Blossom(ServerUrl, pub);
await uploader.delete(id); await uploader.delete(id);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
setError(e.message.length > 0 ? e.message : "Upload failed"); setError(e.message || "Delete failed - no error details provided");
} else if (typeof e === "string") { } else if (typeof e === "string") {
setError(e); setError(e);
} else { } else {
setError("List files failed"); setError("Delete failed");
} }
} }
} }
useEffect(() => { useEffect(() => {
if (pub) { if (pub && !listedFiles) {
listUploads(listedPage); listUploads(listedPage);
} }
}, [listedPage, pub]); }, [listedPage, pub, listUploads, listedFiles]);
useEffect(() => {
if (pub) {
listAllUploads(adminListedPage);
}
}, [adminListedPage, mimeFilter, pub]);
useEffect(() => {
if (pub && self?.is_admin) {
listReports(reportPage);
}
}, [reportPage, pub, self?.is_admin]);
useEffect(() => { useEffect(() => {
if (pub && !self) { if (pub && !self) {
const r96 = new Route96(url, pub); const r96 = new Route96(ServerUrl, pub);
r96.getSelf().then((v) => setSelf(v.data)); r96.getSelf().then((v) => setSelf(v.data));
} }
}, [pub, self]); }, [pub, self]);
if (!login) {
return ( return (
<div className="flex flex-col gap-2 bg-neutral-800 p-8 rounded-xl w-full"> <div className="card max-w-2xl mx-auto text-center">
<h1 className="text-lg font-bold"> <h2 className="text-2xl font-semibold mb-4 text-gray-100">
Welcome to {window.location.hostname} Welcome to {window.location.hostname}
</h1> </h2>
<div className="text-neutral-400 uppercase text-xs font-medium"> <p className="text-gray-400 mb-6">
Upload Method Please log in to start uploading files to your storage.
</p>
</div> </div>
<div className="flex gap-4 items-center"> );
<div }
className="flex gap-2 cursor-pointer"
onClick={() => setType("blossom")} return (
> <div className="w-full px-4">
Blossom {error && (
<input type="radio" checked={type === "blossom"} /> <div className="bg-red-900/20 border border-red-800 text-red-400 px-4 py-3 rounded-lg mb-6">
</div> {error}
<div
className="flex gap-2 cursor-pointer"
onClick={() => setType("nip96")}
>
NIP-96
<input type="radio" checked={type === "nip96"} />
</div> </div>
)}
<div className="flex flex-wrap gap-6">
{/* Upload Widget */}
<div className="card flex-1 min-w-80">
<h2 className="text-xl font-semibold mb-6">Upload Files</h2>
<div className="space-y-6">
<div>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={stripMetadata}
onChange={(e) => setStripMetadata(e.target.checked)}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-300">
Strip metadata (for images)
</span>
</label>
</div> </div>
<div {/* Upload Progress */}
className="flex gap-2 cursor-pointer" {isUploading && uploadProgress && (
onClick={() => setNoCompress((s) => !s)} <ProgressBar
> progress={uploadProgress}
Disable Compression />
<input type="checkbox" checked={noCompress} /> )}
</div>
{toUpload && <FileList files={toUpload ? [toUpload] : []} />}
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
className="flex-1" onClick={handleFileSelection}
onClick={async () => { className="btn-primary flex-1"
const f = await openFile(); disabled={isUploading}
setToUpload(f);
}}
> >
Choose Files {isUploading ? "Uploading..." : "Select Files to Upload"}
</Button>
<Button
className="flex-1"
onClick={doUpload}
disabled={login === undefined}
>
Upload
</Button> </Button>
</div> </div>
<hr />
{!listedFiles && (
<Button disabled={login === undefined} onClick={() => listUploads(0)}>
List Uploads
</Button>
)}
{self && (
<div className="flex justify-between font-medium">
<div>Uploads: {self.file_count.toLocaleString()}</div>
<div>Total Size: {FormatBytes(self.total_size)}</div>
</div> </div>
)} </div>
{/* Storage Usage Widget */}
{self && ( {self && (
<div className="bg-neutral-700 p-4 rounded-lg"> <div className="card flex-1 min-w-80">
<h3 className="text-lg font-bold mb-2">Storage Quota</h3> <h3 className="text-lg font-semibold mb-4">Storage Usage</h3>
<div className="space-y-4">
{/* File Count */}
<div className="flex justify-between text-sm">
<span>Files:</span>
<span className="font-medium">
{self.file_count.toLocaleString()}
</span>
</div>
{/* Total Usage */}
<div className="flex justify-between text-sm">
<span>Total Size:</span>
<span className="font-medium">
{FormatBytes(self.total_size)}
</span>
</div>
{/* Only show quota information if available */}
{self.total_available_quota && self.total_available_quota > 0 && (
<>
{/* Progress Bar */}
<div className="space-y-2"> <div className="space-y-2">
{self.free_quota && ( <div className="flex justify-between text-sm">
<div className="text-sm"> <span>Quota Used:</span>
Free Quota: {FormatBytes(self.free_quota)} <span className="font-medium">
{FormatBytes(self.total_size)} of{" "}
{FormatBytes(self.total_available_quota)}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${self.total_size / self.total_available_quota > 0.8
? "bg-red-500"
: self.total_size / self.total_available_quota > 0.6
? "bg-yellow-500"
: "bg-green-500"
}`}
style={{
width: `${Math.min(100, (self.total_size / self.total_available_quota) * 100)}%`,
}}
></div>
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>
{(
(self.total_size / self.total_available_quota) *
100
).toFixed(1)}
% used
</span>
<span
className={`${self.total_size / self.total_available_quota > 0.8
? "text-red-400"
: self.total_size / self.total_available_quota > 0.6
? "text-yellow-400"
: "text-green-400"
}`}
>
{FormatBytes(
Math.max(
0,
self.total_available_quota - self.total_size,
),
)}{" "}
remaining
</span>
</div>
</div>
{/* Quota Breakdown - excluding free quota */}
<div className="space-y-2 pt-2 border-t border-gray-700">
{(self.quota ?? 0) > 0 && (
<div className="flex justify-between text-sm">
<span>Paid Quota:</span>
<span className="font-medium">
{FormatBytes(self.quota!)}
</span>
</div> </div>
)} )}
{self.quota && ( {(self.paid_until ?? 0) > 0 && (
<div className="text-sm"> <div className="flex justify-between text-sm">
Paid Quota: {FormatBytes(self.quota)} <span>Expires:</span>
<div className="text-right">
<div className="font-medium">
{new Date(
self.paid_until! * 1000,
).toLocaleDateString()}
</div>
<div className="text-xs text-gray-400">
{(() => {
const now = Date.now() / 1000;
const daysLeft = Math.max(
0,
Math.ceil(
(self.paid_until! - now) / (24 * 60 * 60),
),
);
return daysLeft > 0
? `${daysLeft} days left`
: "Expired";
})()}
</div>
</div>
</div> </div>
)} )}
{self.total_available_quota && (
<div className="text-sm font-medium">
Total Available: {FormatBytes(self.total_available_quota)}
</div>
)}
{self.total_available_quota && (
<div className="text-sm">
Remaining: {FormatBytes(Math.max(0, self.total_available_quota - self.total_size))}
</div>
)}
{self.paid_until && (
<div className="text-sm text-neutral-300">
Paid Until: {new Date(self.paid_until * 1000).toLocaleDateString()}
</div> </div>
</>
)} )}
</div> </div>
<Button <Button
onClick={() => setShowPaymentFlow(!showPaymentFlow)} onClick={() => setShowPaymentFlow(!showPaymentFlow)}
className="mt-3 w-full" className="btn-primary w-full mt-4"
> >
{showPaymentFlow ? "Hide" : "Show"} Top Up Options {showPaymentFlow ? "Hide" : "Show"} Payment Options
</Button> </Button>
</div> </div>
)} )}
{/* Payment Flow Widget */}
{showPaymentFlow && pub && ( {showPaymentFlow && pub && (
<div className="card flex-1 min-w-80">
<PaymentFlow <PaymentFlow
route96={new Route96(url, pub)} route96={new Route96(ServerUrl, pub)}
onPaymentRequested={(pr) => { onPaymentRequested={(pr) => {
console.log("Payment requested:", pr); console.log("Payment requested:", pr);
// You could add more logic here, like showing a QR code
}} }}
userInfo={self}
/> />
</div>
)} )}
{/* Mirror Suggestions Widget */}
{blossomServers && blossomServers.length > 1 && (
<div className="w-full">
<MirrorSuggestions
servers={blossomServers}
/>
</div>
)}
{/* Files Widget */}
<div className="card w-full">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Files</h2>
{!listedFiles && (
<Button onClick={() => listUploads(0)} className="btn-primary">
Load Files
</Button>
)}
</div>
{listedFiles && ( {listedFiles && (
<FileList <FileList
files={listedFiles.files} files={listedFiles.files}
@ -305,59 +372,90 @@ export default function Upload() {
}} }}
/> />
)} )}
{self?.is_admin && (
<>
<hr />
<h3>Admin File List:</h3>
<Button onClick={() => listAllUploads(0)}>List All Uploads</Button>
<Button onClick={() => listReports(0)}>List Reports</Button>
<div>
<select value={mimeFilter} onChange={e => setMimeFilter(e.target.value)}>
<option value={""}>All</option>
<option>image/webp</option>
<option>image/jpeg</option>
<option>image/jpg</option>
<option>image/png</option>
<option>image/gif</option>
<option>video/mp4</option>
<option>video/mov</option>
</select>
</div> </div>
{adminListedFiles && (
<FileList {/* Upload Results Widget */}
files={adminListedFiles.files} {results.length > 0 && (
pages={Math.ceil(adminListedFiles.total / adminListedFiles.count)} <div className="card w-full">
page={adminListedFiles.page} <h3 className="text-lg font-semibold mb-4">Upload Results</h3>
onPage={(x) => setAdminListedPage(x)} <div className="space-y-4">
onDelete={async (x) => { {results.map((result, index) => (
await deleteFile(x); <div
await listAllUploads(adminListedPage); key={index}
}} className="bg-gray-800 border border-gray-700 rounded-lg p-4"
/> >
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-medium text-green-400 mb-1">
Upload Successful
</h4>
<p className="text-sm text-gray-400">
{new Date(
(result.uploaded || Date.now() / 1000) * 1000,
).toLocaleString()}
</p>
</div>
<div className="text-right">
<span className="text-xs bg-blue-900/50 text-blue-300 px-2 py-1 rounded">
{result.type || "Unknown type"}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-400">File Size</p>
<p className="font-medium">
{FormatBytes(result.size || 0)}
</p>
</div>
</div>
<div className="space-y-2">
{result.url && (
<div>
<p className="text-sm text-gray-400 mb-1">File URL</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-900 text-green-400 px-2 py-1 rounded flex-1 overflow-hidden">
{result.url}
</code>
<button
onClick={() =>
navigator.clipboard.writeText(result.url!)
}
className="text-xs bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded transition-colors"
title="Copy URL"
>
Copy
</button>
</div>
</div>
)} )}
{reports && (
<> <div>
<h3>Reports:</h3> <p className="text-sm text-gray-400 mb-1">
<ReportList File Hash (SHA256)
reports={reports} </p>
pages={reportPages} <code className="text-xs bg-gray-900 text-gray-400 px-2 py-1 rounded block overflow-hidden">
page={reportPage} {result.sha256}
onPage={(x) => setReportPage(x)} </code>
onAcknowledge={acknowledgeReport} </div>
onDeleteFile={async (fileId) => { </div>
await deleteFile(fileId);
await listReports(reportPage); // Refresh reports after deleting file <details className="mt-4">
}} <summary className="text-sm text-gray-400 cursor-pointer hover:text-gray-300">
/> Show raw JSON data
</> </summary>
)} <pre className="text-xs bg-gray-900 text-gray-300 p-3 rounded mt-2 overflow-auto">
</> {JSON.stringify(result, undefined, 2)}
)}
{error && <b className="text-red-500">{error}</b>}
<pre className="text-xs font-monospace overflow-wrap">
{JSON.stringify(results, undefined, 2)}
</pre> </pre>
</details>
</div>
))}
</div>
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"errors":true,"version":"5.8.3"} {"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/mirror-suggestions.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/hooks/use-blossom-servers.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"}

View File

@ -1 +1 @@
{"root":["./vite.config.ts"],"errors":true,"version":"5.8.3"} {"root":["./vite.config.ts"],"version":"5.6.2"}

View File

@ -993,6 +993,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/history@npm:^4.7.11":
version: 4.7.11
resolution: "@types/history@npm:4.7.11"
checksum: 10c0/3facf37c2493d1f92b2e93a22cac7ea70b06351c2ab9aaceaa3c56aa6099fb63516f6c4ec1616deb5c56b4093c026a043ea2d3373e6c0644d55710364d02c934
languageName: node
linkType: hard
"@types/json-schema@npm:^7.0.15": "@types/json-schema@npm:^7.0.15":
version: 7.0.15 version: 7.0.15
resolution: "@types/json-schema@npm:7.0.15" resolution: "@types/json-schema@npm:7.0.15"
@ -1016,6 +1023,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/react-router-dom@npm:^5.3.3":
version: 5.3.3
resolution: "@types/react-router-dom@npm:5.3.3"
dependencies:
"@types/history": "npm:^4.7.11"
"@types/react": "npm:*"
"@types/react-router": "npm:*"
checksum: 10c0/a9231a16afb9ed5142678147eafec9d48582809295754fb60946e29fcd3757a4c7a3180fa94b45763e4c7f6e3f02379e2fcb8dd986db479dcab40eff5fc62a91
languageName: node
linkType: hard
"@types/react-router@npm:*":
version: 5.1.20
resolution: "@types/react-router@npm:5.1.20"
dependencies:
"@types/history": "npm:^4.7.11"
"@types/react": "npm:*"
checksum: 10c0/1f7eee61981d2f807fa01a34a0ef98ebc0774023832b6611a69c7f28fdff01de5a38cabf399f32e376bf8099dcb7afaf724775bea9d38870224492bea4cb5737
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:^18.3.3": "@types/react@npm:*, @types/react@npm:^18.3.3":
version: 18.3.8 version: 18.3.8
resolution: "@types/react@npm:18.3.8" resolution: "@types/react@npm:18.3.8"
@ -1522,6 +1550,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"cookie@npm:^1.0.1":
version: 1.0.2
resolution: "cookie@npm:1.0.2"
checksum: 10c0/fd25fe79e8fbcfcaf6aa61cd081c55d144eeeba755206c058682257cb38c4bd6795c6620de3f064c740695bb65b7949ebb1db7a95e4636efb8357a335ad3f54b
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2": "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2":
version: 7.0.3 version: 7.0.3
resolution: "cross-spawn@npm:7.0.3" resolution: "cross-spawn@npm:7.0.3"
@ -3187,6 +3222,34 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-router-dom@npm:^7.6.2":
version: 7.6.2
resolution: "react-router-dom@npm:7.6.2"
dependencies:
react-router: "npm:7.6.2"
peerDependencies:
react: ">=18"
react-dom: ">=18"
checksum: 10c0/9a8370333b5c1ada5ed76a2c30a90ca5a5a8e6c8565165f147fb42b150f2b258b9e73935fe4945c459d770841abdfaf99c28f7e13da93b1f49b28e6a8e87aadb
languageName: node
linkType: hard
"react-router@npm:7.6.2":
version: 7.6.2
resolution: "react-router@npm:7.6.2"
dependencies:
cookie: "npm:^1.0.1"
set-cookie-parser: "npm:^2.6.0"
peerDependencies:
react: ">=18"
react-dom: ">=18"
peerDependenciesMeta:
react-dom:
optional: true
checksum: 10c0/c8ef65f2a378f38e3cba900d67fa2b80a41c1c3925102875ee07c12faa01ea40991cb3fbefaf3ff6914e724c755732e3d7dec2b1bdef09e0fddd00fccc85a06a
languageName: node
linkType: hard
"react@npm:^18.2.0, react@npm:^18.3.1": "react@npm:^18.2.0, react@npm:^18.3.1":
version: 18.3.1 version: 18.3.1
resolution: "react@npm:18.3.1" resolution: "react@npm:18.3.1"
@ -3367,6 +3430,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"set-cookie-parser@npm:^2.6.0":
version: 2.7.1
resolution: "set-cookie-parser@npm:2.7.1"
checksum: 10c0/060c198c4c92547ac15988256f445eae523f57f2ceefeccf52d30d75dedf6bff22b9c26f756bd44e8e560d44ff4ab2130b178bd2e52ef5571bf7be3bd7632d9a
languageName: node
linkType: hard
"shebang-command@npm:^2.0.0": "shebang-command@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "shebang-command@npm:2.0.0" resolution: "shebang-command@npm:2.0.0"
@ -3726,6 +3796,7 @@ __metadata:
"@snort/system-react": "npm:^1.5.1" "@snort/system-react": "npm:^1.5.1"
"@types/react": "npm:^18.3.3" "@types/react": "npm:^18.3.3"
"@types/react-dom": "npm:^18.3.0" "@types/react-dom": "npm:^18.3.0"
"@types/react-router-dom": "npm:^5.3.3"
"@vitejs/plugin-react": "npm:^4.3.1" "@vitejs/plugin-react": "npm:^4.3.1"
autoprefixer: "npm:^10.4.20" autoprefixer: "npm:^10.4.20"
classnames: "npm:^2.5.1" classnames: "npm:^2.5.1"
@ -3737,6 +3808,7 @@ __metadata:
prettier: "npm:^3.3.3" prettier: "npm:^3.3.3"
react: "npm:^18.3.1" react: "npm:^18.3.1"
react-dom: "npm:^18.3.1" react-dom: "npm:^18.3.1"
react-router-dom: "npm:^7.6.2"
tailwindcss: "npm:^3.4.13" tailwindcss: "npm:^3.4.13"
typescript: "npm:^5.5.3" typescript: "npm:^5.5.3"
typescript-eslint: "npm:^8.0.1" typescript-eslint: "npm:^8.0.1"