diff --git a/README.md b/README.md index 9b1841a..20d05a8 100644 --- a/README.md +++ b/README.md @@ -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) -- [Blossom Support](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - - [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - - [BUD-02](https://github.com/hzrd149/blossom/blob/master/buds/02.md) - - [BUD-04](https://github.com/hzrd149/blossom/blob/master/buds/04.md) - - [BUD-05](https://github.com/hzrd149/blossom/blob/master/buds/05.md) - - [BUD-06](https://github.com/hzrd149/blossom/blob/master/buds/06.md) - - [BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md) -- Image compression to WebP -- Blurhash calculation -- AI image labeling ([ViT224](https://huggingface.co/google/vit-base-patch16-224)) -- Plausible analytics +### Protocol Support +- **[NIP-96](https://github.com/nostr-protocol/nips/blob/master/96.md)** - Nostr file storage with media processing +- **[Blossom Protocol](https://github.com/hzrd149/blossom)** - Complete BUD specification compliance: + - [BUD-01](https://github.com/hzrd149/blossom/blob/master/buds/01.md) - Blob retrieval (GET/HEAD) + - [BUD-02](https://github.com/hzrd149/blossom/blob/master/buds/02.md) - Upload, delete, list operations + - [BUD-04](https://github.com/hzrd149/blossom/blob/master/buds/04.md) - Blob mirroring from remote servers + - [BUD-05](https://github.com/hzrd149/blossom/blob/master/buds/05.md) - Media optimization endpoints + - [BUD-06](https://github.com/hzrd149/blossom/blob/master/buds/06.md) - Upload requirement validation + - [BUD-08](https://github.com/hzrd149/blossom/blob/master/buds/08.md) - NIP-94 metadata support + - [BUD-09](https://github.com/hzrd149/blossom/blob/master/buds/09.md) - Content reporting system -## 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 -- Payment system +### Security & Administration +- **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 /` - Retrieve blob by hash +- `HEAD /` - Check blob existence +- `PUT /upload` - Upload new blob +- `DELETE /` - Delete owned blob +- `GET /list/` - 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/` - 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 diff --git a/migrations/20250202135844_payments.sql b/migrations/20250202135844_payments.sql index c86a593..4fc587d 100644 --- a/migrations/20250202135844_payments.sql +++ b/migrations/20250202135844_payments.sql @@ -1,7 +1,7 @@ -- Add migration script here alter table users add column paid_until timestamp, - add column paid_size integer unsigned not null; + add column paid_size bigint unsigned not null; create table payments ( @@ -11,7 +11,7 @@ create table payments amount integer unsigned not null, is_paid bit(1) not null default 0, days_value integer unsigned not null, - size_value integer unsigned not null, + size_value bigint unsigned not null, settle_index integer unsigned, rate float, diff --git a/src/db.rs b/src/db.rs index 367ad17..569ca51 100644 --- a/src/db.rs +++ b/src/db.rs @@ -139,14 +139,23 @@ impl Database { .bind(pubkey) .fetch_optional(&self.pool) .await?; - match res { + let user_id = match res { None => sqlx::query("select id from users where pubkey = ?") .bind(pubkey) .fetch_one(&self.pool) .await? - .try_get(0), - Some(res) => res.try_get(0), + .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) -> Result { diff --git a/src/routes/blossom.rs b/src/routes/blossom.rs index f59b4ea..b86f222 100644 --- a/src/routes/blossom.rs +++ b/src/routes/blossom.rs @@ -24,7 +24,7 @@ pub struct BlobDescriptor { pub size: u64, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub mime_type: Option, - pub created: u64, + pub uploaded: u64, #[serde(rename = "nip94", skip_serializing_if = "Option::is_none")] pub nip94: Option>>, } @@ -44,7 +44,7 @@ impl BlobDescriptor { sha256: id_hex, size: value.size, 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), } } @@ -352,11 +352,16 @@ async fn process_upload( None } }); - if let Some(z) = size { - if z > settings.max_upload_bytes { - return BlossomResponse::error("File too large"); + + let size = match size { + Some(z) => { + if z > settings.max_upload_bytes { + return BlossomResponse::error("File too large"); + } + z } - } + None => return BlossomResponse::error("Size tag is required"), + }; // check whitelist if let Some(e) = check_whitelist(&auth, settings) { @@ -365,7 +370,7 @@ async fn process_upload( // check quota #[cfg(feature = "payments")] - if let Some(upload_size) = size { + { let free_quota = settings .payments .as_ref() @@ -374,7 +379,7 @@ async fn process_upload( let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); match db - .check_user_quota(&pubkey_vec, upload_size, free_quota) + .check_user_quota(&pubkey_vec, size, free_quota) .await { Ok(false) => return BlossomResponse::error("Upload would exceed quota"), @@ -425,7 +430,7 @@ where _ => return BlossomResponse::error("File not found"), }, Err(e) => { - error!("{}", e.to_string()); + error!("{}", e); return BlossomResponse::error(format!("Error saving file (disk): {}", e)); } }; @@ -437,7 +442,7 @@ where } }; if let Err(e) = db.add_file(&upload, user_id).await { - error!("{}", e.to_string()); + error!("{}", e); BlossomResponse::error(format!("Error saving file (db): {}", e)) } else { BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from_upload(settings, &upload))) diff --git a/ui_src/package.json b/ui_src/package.json index 19ca088..fcf1897 100644 --- a/ui_src/package.json +++ b/ui_src/package.json @@ -16,12 +16,14 @@ "@snort/system-react": "^1.5.1", "classnames": "^2.5.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^7.6.2" }, "devDependencies": { "@eslint/js": "^9.9.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "eslint": "^9.9.0", diff --git a/ui_src/src/App.tsx b/ui_src/src/App.tsx index a2af150..2989516 100644 --- a/ui_src/src/App.tsx +++ b/ui_src/src/App.tsx @@ -1,12 +1,23 @@ +import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import Header from "./views/header"; import Upload from "./views/upload"; +import Admin from "./views/admin"; function App() { return ( -
-
- -
+ +
+
+
+
+ + } /> + } /> + +
+
+
+
); } diff --git a/ui_src/src/components/button.tsx b/ui_src/src/components/button.tsx index b7d8e60..3b6c88f 100644 --- a/ui_src/src/components/button.tsx +++ b/ui_src/src/components/button.tsx @@ -22,12 +22,12 @@ export default function Button({ } return ( ); } diff --git a/ui_src/src/components/payment.tsx b/ui_src/src/components/payment.tsx index 8ce4282..d231cb0 100644 --- a/ui_src/src/components/payment.tsx +++ b/ui_src/src/components/payment.tsx @@ -9,8 +9,8 @@ interface PaymentFlowProps { export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlowProps) { const [paymentInfo, setPaymentInfo] = useState(null); - const [units, setUnits] = useState(1); - const [quantity, setQuantity] = useState(1); + const [gigabytes, setGigabytes] = useState(1); + const [months, setMonths] = useState(1); const [paymentRequest, setPaymentRequest] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -41,7 +41,7 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow setError(""); try { - const request: PaymentRequest = { units, quantity }; + const request: PaymentRequest = { units: gigabytes, quantity: months }; const response = await route96.requestPayment(request); setPaymentRequest(response.pr); onPaymentRequested?.(response.pr); @@ -57,71 +57,86 @@ export default function PaymentFlow({ route96, onPaymentRequested }: PaymentFlow } if (error && !paymentInfo) { - return
Payment not available: {error}
; + return
Payment not available: {error}
; } if (!paymentInfo) { - return
Loading payment info...
; + return
Loading payment info...
; } - 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 ( -
+

Top Up Account

-
-
- - setUnits(parseFloat(e.target.value) || 0)} - className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded" - /> +
+
+
+ {gigabytes} {formatStorageUnit(paymentInfo.unit)} for {months} month{months > 1 ? 's' : ''} +
+
+ {totalCostSats.toLocaleString()} sats +
-
- - setQuantity(parseInt(e.target.value) || 1)} - className="w-full px-3 py-2 bg-neutral-800 border border-neutral-600 rounded" - /> +
+
+ + setGigabytes(parseInt(e.target.value) || 1)} + className="input w-full text-center text-lg" + /> +
+ +
+ + setMonths(parseInt(e.target.value) || 1)} + className="input w-full text-center text-lg" + /> +
-
-
- Cost: {totalCost.toFixed(8)} {paymentInfo.cost.currency} per {paymentInfo.interval} -
-
- {error &&
{error}
} + {error &&
{error}
} {paymentRequest && ( -
+
Lightning Invoice:
-
+
{paymentRequest}
-
+
Copy this invoice to your Lightning wallet to complete payment
diff --git a/ui_src/src/components/profile.tsx b/ui_src/src/components/profile.tsx index b22a487..d14347c 100644 --- a/ui_src/src/components/profile.tsx +++ b/ui_src/src/components/profile.tsx @@ -1,6 +1,7 @@ import { hexToBech32 } from "@snort/shared"; import { NostrLink } from "@snort/system"; import { useUserProfile } from "@snort/system-react"; +import { useMemo } from "react"; export default function Profile({ link, @@ -11,15 +12,21 @@ export default function Profile({ size?: number; showName?: boolean; }) { - const profile = useUserProfile(link.id); + const linkId = useMemo(() => link.id, [link.id]); + const profile = useUserProfile(linkId); const s = size ?? 40; return ( {profile?.display_name { + const target = e.target as HTMLImageElement; + target.src = '/default-avatar.svg'; + }} /> {(showName ?? true) && (
diff --git a/ui_src/src/hooks/publisher.ts b/ui_src/src/hooks/publisher.ts index 13add14..d1b6577 100644 --- a/ui_src/src/hooks/publisher.ts +++ b/ui_src/src/hooks/publisher.ts @@ -1,10 +1,16 @@ import { EventPublisher, Nip7Signer } from "@snort/system"; +import { useMemo } from "react"; import useLogin from "./login"; export default function usePublisher() { const login = useLogin(); - switch (login?.type) { - case "nip7": - return new EventPublisher(new Nip7Signer(), login.pubkey); - } + + return useMemo(() => { + switch (login?.type) { + case "nip7": + return new EventPublisher(new Nip7Signer(), login.pubkey); + default: + return undefined; + } + }, [login?.type, login?.pubkey]); } diff --git a/ui_src/src/index.css b/ui_src/src/index.css index 98de3ad..97040c5 100644 --- a/ui_src/src/index.css +++ b/ui_src/src/index.css @@ -4,9 +4,49 @@ html, 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 { - @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; } diff --git a/ui_src/src/login.ts b/ui_src/src/login.ts index 7a8e750..7274a85 100644 --- a/ui_src/src/login.ts +++ b/ui_src/src/login.ts @@ -13,6 +13,11 @@ class LoginStore extends ExternalStore { this.notifyChange(); } + logout() { + this.#session = undefined; + this.notifyChange(); + } + takeSnapshot(): LoginSession | undefined { return this.#session ? { ...this.#session } : undefined; } diff --git a/ui_src/src/upload/admin.ts b/ui_src/src/upload/admin.ts index 3d8557e..62cb30c 100644 --- a/ui_src/src/upload/admin.ts +++ b/ui_src/src/upload/admin.ts @@ -23,7 +23,9 @@ export interface Report { export interface PaymentInfo { unit: string; - interval: string; + interval: { + [key: string]: number; + }; cost: { currency: string; amount: number; diff --git a/ui_src/src/views/admin.tsx b/ui_src/src/views/admin.tsx new file mode 100644 index 0000000..4be3c91 --- /dev/null +++ b/ui_src/src/views/admin.tsx @@ -0,0 +1,238 @@ +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(); + const [error, setError] = useState(); + const [adminListedFiles, setAdminListedFiles] = useState(); + const [reports, setReports] = useState(); + const [reportPages, setReportPages] = useState(); + const [reportPage, setReportPage] = useState(0); + const [adminListedPage, setAdminListedPage] = useState(0); + const [mimeFilter, setMimeFilter] = useState(); + 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 && !adminListedFiles) { + listAllUploads(adminListedPage); + } + }, [adminListedPage, pub, self?.is_admin, listAllUploads, adminListedFiles]); + + useEffect(() => { + if (pub && self?.is_admin && !reports) { + listReports(reportPage); + } + }, [reportPage, pub, self?.is_admin, listReports, reports]); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!login) { + return ( +
+

Authentication Required

+

Please log in to access the admin panel.

+
+ ); + } + + if (!self?.is_admin) { + return ; + } + + return ( +
+
+

Admin Panel

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+

File Management

+ +
+
+ + +
+ + +
+
+ +
+

Reports Management

+ + +
+
+ + {adminListedFiles && ( +
+

All Files

+ setAdminListedPage(x)} + onDelete={async (x) => { + await deleteFile(x); + await listAllUploads(adminListedPage); + }} + /> +
+ )} + + {reports && ( +
+

Reports

+ setReportPage(x)} + onAcknowledge={acknowledgeReport} + onDeleteFile={async (fileId) => { + await deleteFile(fileId); + await listReports(reportPage); + }} + /> +
+ )} +
+ ); +} \ No newline at end of file diff --git a/ui_src/src/views/files.tsx b/ui_src/src/views/files.tsx index 376bbdf..1caf99e 100644 --- a/ui_src/src/views/files.tsx +++ b/ui_src/src/views/files.tsx @@ -28,7 +28,7 @@ export default function FileList({ }) { const [viewType, setViewType] = useState<"grid" | "list">("grid"); if (files.length === 0) { - return No Files; + return No Files; } function renderInner(f: FileInfo) { @@ -77,19 +77,21 @@ export default function FileList({ for (let x = start; x < n; x++) { ret.push( -
onPage?.(x)} 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-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} -
, + , ); } @@ -98,49 +100,48 @@ export default function FileList({ function showGrid() { return ( -
+
{files.map((a) => { const info = getInfo(a); return (
-
-
+
+
{(info.name?.length ?? 0) === 0 ? "Untitled" : info.name!.length > 20 ? `${info.name?.substring(0, 10)}...${info.name?.substring(info.name.length - 10)}` : info.name}
-
{renderInner(info)} @@ -153,106 +154,123 @@ export default function FileList({ function showList() { return ( - - - - - - - - {files.some((i) => "uploader" in i) && ( - + ); + })} + +
- Preview - - Name - - Type - - Size - - Uploader +
+ + + + - )} - - - - - {files.map((a) => { - const info = getInfo(a); - return ( - - - - - - {info.uploader && ( - + + + {files.some((i) => "uploader" in i) && ( + + )} + + + + + {files.map((a) => { + const info = getInfo(a); + return ( + + - )} - + + + {info.uploader && ( + + )} + - - ); - })} - -
+ Preview - Actions -
- {renderInner(info)} - - {(info.name?.length ?? 0) === 0 ? "" : info.name} - - {info.type} - - {info.size && !isNaN(info.size) - ? FormatBytes(info.size, 2) - : ""} - - {info.uploader.map((a) => ( - - ))} + + Name + + Type + + Size + + Uploader + + Actions +
+
+ {renderInner(info)} +
- + {(info.name?.length ?? 0) === 0 ? "" : info.name} + + {info.type} + + {info.size && !isNaN(info.size) + ? FormatBytes(info.size, 2) + : ""} + + {info.uploader.map((a, idx) => ( + + ))} + +
+ + View - )} -
-
+ {onDelete && ( + + )} +
+ +
+
); } return ( - <> -
-
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" : ""}`} - > - Grid -
-
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" : ""}`} - > - List +
+
+
+ +
+ {viewType === "grid" ? showGrid() : showList()} - {pages !== undefined && ( - <> -
{pageButtons(page ?? 0, pages)}
- + + {pages !== undefined && pages > 1 && ( +
+
+ {pageButtons(page ?? 0, pages)} +
+
)} - +
); } diff --git a/ui_src/src/views/header.tsx b/ui_src/src/views/header.tsx index 73ac684..13d7b76 100644 --- a/ui_src/src/views/header.tsx +++ b/ui_src/src/views/header.tsx @@ -1,11 +1,21 @@ import { Nip7Signer, NostrLink } from "@snort/system"; +import { Link, useLocation } from "react-router-dom"; +import { useEffect, useState } from "react"; import Button from "../components/button"; import Profile from "../components/profile"; import useLogin from "../hooks/login"; +import usePublisher from "../hooks/publisher"; import { Login } from "../login"; +import { AdminSelf, Route96 } from "../upload/admin"; export default function Header() { const login = useLogin(); + const pub = usePublisher(); + const location = useLocation(); + const [self, setSelf] = useState(); + + const url = + import.meta.env.VITE_API_URL || `${window.location.protocol}//${window.location.host}`; async function tryLogin() { try { @@ -19,14 +29,69 @@ export default function Header() { //ignore } } + + useEffect(() => { + if (pub && !self) { + const r96 = new Route96(url, pub); + r96.getSelf().then((v) => setSelf(v.data)).catch(() => {}); + } + }, [pub, self, url]); + return ( -
-
route96
- {login ? ( - - ) : ( - - )} -
+
+
+
+ +
+ route96 +
+ + + +
+ +
+ {login ? ( +
+ + +
+ ) : ( + + )} +
+
+
); } diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx index 2114b73..933faf4 100644 --- a/ui_src/src/views/upload.tsx +++ b/ui_src/src/views/upload.tsx @@ -1,14 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import Button from "../components/button"; import FileList from "./files"; -import ReportList from "./reports"; import PaymentFlow from "../components/payment"; import { openFile } from "../upload"; import { Blossom } from "../upload/blossom"; import useLogin from "../hooks/login"; import usePublisher from "../hooks/publisher"; import { Nip96, Nip96FileList } from "../upload/nip96"; -import { AdminSelf, Route96, Report } from "../upload/admin"; +import { AdminSelf, Route96 } from "../upload/admin"; import { FormatBytes } from "../const"; export default function Upload() { @@ -19,13 +18,7 @@ export default function Upload() { const [error, setError] = useState(); const [results, setResults] = useState>([]); const [listedFiles, setListedFiles] = useState(); - const [adminListedFiles, setAdminListedFiles] = useState(); - const [reports, setReports] = useState(); - const [reportPages, setReportPages] = useState(); - const [reportPage, setReportPage] = useState(0); const [listedPage, setListedPage] = useState(0); - const [adminListedPage, setAdminListedPage] = useState(0); - const [mimeFilter, setMimeFilter] = useState(); const [showPaymentFlow, setShowPaymentFlow] = useState(false); const login = useLogin(); @@ -62,7 +55,7 @@ export default function Upload() { } } - async function listUploads(n: number) { + const listUploads = useCallback(async (n: number) => { if (!pub) return; try { setError(undefined); @@ -79,62 +72,8 @@ export default function Upload() { setError("List files failed"); } } - } + }, [pub, url]); - 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) { if (!pub) return; @@ -154,210 +93,217 @@ export default function Upload() { } useEffect(() => { - if (pub) { + if (pub && !listedFiles) { 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(() => { if (pub && !self) { const r96 = new Route96(url, pub); r96.getSelf().then((v) => setSelf(v.data)); } - }, [pub, self]); + }, [pub, self, url]); + + if (!login) { + return ( +
+

Welcome to {window.location.hostname}

+

Please log in to start uploading files to your storage.

+
+ ); + } return ( -
-

- Welcome to {window.location.hostname} -

-
- Upload Method -
-
-
setType("blossom")} - > - Blossom - -
-
setType("nip96")} - > - NIP-96 - -
+
+
+

+ Welcome to {window.location.hostname} +

+

Upload and manage your files securely

-
setNoCompress((s) => !s)} - > - Disable Compression - -
- - {toUpload && } -
- - -
-
- {!listedFiles && ( - - )} - - {self && ( -
-
Uploads: {self.file_count.toLocaleString()}
-
Total Size: {FormatBytes(self.total_size)}
+ {error && ( +
+ {error}
)} - {self && ( -
-

Storage Quota

-
- {self.free_quota && ( -
- Free Quota: {FormatBytes(self.free_quota)} -
- )} - {self.quota && ( -
- Paid Quota: {FormatBytes(self.quota)} -
- )} - {self.total_available_quota && ( -
- Total Available: {FormatBytes(self.total_available_quota)} -
- )} - {self.total_available_quota && ( -
- Remaining: {FormatBytes(Math.max(0, self.total_available_quota - self.total_size))} -
- )} - {self.paid_until && ( -
- Paid Until: {new Date(self.paid_until * 1000).toLocaleDateString()} -
- )} +
+

Upload Settings

+ +
+
+ +
+ + +
+
+ +
+ +
+ + {toUpload && ( +
+ +
+ )} + +
+ + +
+
+
+ + {self && ( +
+
+

Storage Usage

+
+
+ Files: + {self.file_count.toLocaleString()} +
+
+ Total Size: + {FormatBytes(self.total_size)} +
+
+
+ +
+

Storage Quota

+
+ {self.free_quota && ( +
+ Free Quota: + {FormatBytes(self.free_quota)} +
+ )} + {self.quota && ( +
+ Paid Quota: + {FormatBytes(self.quota)} +
+ )} + {self.total_available_quota && ( +
+ Total Available: + {FormatBytes(self.total_available_quota)} +
+ )} + {self.total_available_quota && ( +
+ Remaining: + + {FormatBytes(Math.max(0, self.total_available_quota - self.total_size))} + +
+ )} + {self.paid_until && ( +
+ Paid Until: + {new Date(self.paid_until * 1000).toLocaleDateString()} +
+ )} +
+
-
)} {showPaymentFlow && pub && ( - { - console.log("Payment requested:", pr); - // You could add more logic here, like showing a QR code - }} - /> +
+ { + console.log("Payment requested:", pr); + }} + /> +
)} - {listedFiles && ( - setListedPage(x)} - onDelete={async (x) => { - await deleteFile(x); - await listUploads(listedPage); - }} - /> - )} +
+
+

Your Files

+ {!listedFiles && ( + + )} +
+ + {listedFiles && ( + setListedPage(x)} + onDelete={async (x) => { + await deleteFile(x); + await listUploads(listedPage); + }} + /> + )} +
- {self?.is_admin && ( - <> -
-

Admin File List:

- - -
- -
- {adminListedFiles && ( - setAdminListedPage(x)} - onDelete={async (x) => { - await deleteFile(x); - await listAllUploads(adminListedPage); - }} - /> - )} - {reports && ( - <> -

Reports:

- setReportPage(x)} - onAcknowledge={acknowledgeReport} - onDeleteFile={async (fileId) => { - await deleteFile(fileId); - await listReports(reportPage); // Refresh reports after deleting file - }} - /> - - )} - + {results.length > 0 && ( +
+

Upload Results

+
+            {JSON.stringify(results, undefined, 2)}
+          
+
)} - {error && {error}} -
-        {JSON.stringify(results, undefined, 2)}
-      
); } diff --git a/ui_src/tsconfig.app.tsbuildinfo b/ui_src/tsconfig.app.tsbuildinfo index e016a1a..f63ed5c 100644 --- a/ui_src/tsconfig.app.tsbuildinfo +++ b/ui_src/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"} \ No newline at end of file diff --git a/ui_src/tsconfig.node.tsbuildinfo b/ui_src/tsconfig.node.tsbuildinfo index f17a3a9..98ef2f9 100644 --- a/ui_src/tsconfig.node.tsbuildinfo +++ b/ui_src/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"errors":true,"version":"5.8.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"version":"5.6.2"} \ No newline at end of file diff --git a/ui_src/yarn.lock b/ui_src/yarn.lock index a4e2f18..7fd90da 100644 --- a/ui_src/yarn.lock +++ b/ui_src/yarn.lock @@ -993,6 +993,13 @@ __metadata: languageName: node 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": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -1016,6 +1023,27 @@ __metadata: languageName: node 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": version: 18.3.8 resolution: "@types/react@npm:18.3.8" @@ -1522,6 +1550,13 @@ __metadata: languageName: node 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": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -3187,6 +3222,34 @@ __metadata: languageName: node 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": version: 18.3.1 resolution: "react@npm:18.3.1" @@ -3367,6 +3430,13 @@ __metadata: languageName: node 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": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -3726,6 +3796,7 @@ __metadata: "@snort/system-react": "npm:^1.5.1" "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" + "@types/react-router-dom": "npm:^5.3.3" "@vitejs/plugin-react": "npm:^4.3.1" autoprefixer: "npm:^10.4.20" classnames: "npm:^2.5.1" @@ -3737,6 +3808,7 @@ __metadata: prettier: "npm:^3.3.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" + react-router-dom: "npm:^7.6.2" tailwindcss: "npm:^3.4.13" typescript: "npm:^5.5.3" typescript-eslint: "npm:^8.0.1"