diff --git a/.drone.yml b/.drone.yml index ae72037..3fe2d8b 100644 --- a/.drone.yml +++ b/.drone.yml @@ -19,4 +19,4 @@ steps: - docker login -u kieran -p $TOKEN git.v0l.io - docker login -u voidic -p $TOKEN_DOCKER - docker buildx build --push -t git.v0l.io/kieran/route96:latest -t voidic/route96:latest --build-arg FEATURES="labels" . - - kill $(cat /var/run/docker.pid) \ No newline at end of file + - kill $(cat /var/run/docker.pid) diff --git a/Cargo.lock b/Cargo.lock index 6308ccc..eaac554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-conservative" diff --git a/Cargo.toml b/Cargo.toml index ce7ab25..4bb6c21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ pretty_env_logger = "0.5.0" rocket = { version = "0.5.0", features = ["json"] } tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] } base64 = "0.22.1" -hex = "0.4.3" +hex = { version = "0.4.3", features = ["serde"] } serde = { version = "1.0.198", features = ["derive"] } uuid = { version = "1.8.0", features = ["v4"] } anyhow = "1.0.82" diff --git a/README.md b/README.md index 50458b3..923f086 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Image hosting service ## 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) @@ -13,18 +14,23 @@ Image hosting service - AI image labeling ([ViT224](https://huggingface.co/google/vit-base-patch16-224)) ## Planned + - Torrent seed V2 ## Running ### Docker Compose + The easiest way to run `route96` is to use `docker compose` ```bash docker compose -f docker-compose.prod.yml up ``` + ### Manual + Assuming you already created your `config.toml` and configured the `database` run: + ```bash docker run --rm -it \ -p 8000:8000 \ @@ -36,19 +42,25 @@ docker run --rm -it \ ## Building ### Feature Flags + Default = `nip96` & `blossom` + - `nip96`: Enable NIP-96 support - `blossom`: Enable blossom support - `labels`: Enable AI image labeling (Depends on `nip96`) -### Default build: +### Default build: + `cargo build --release` ### Build only Blossom support + `cargo build --release --no-default-features --features blossom` ### Build dependencies + If you want to support NIP-96 you will need the following dependencies: + ```bash libavcodec-dev libavformat-dev libswscale-dev libavutil-dev libavdevice-dev libavfilter-dev -``` \ No newline at end of file +``` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a2866db..1c58c6d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,4 +18,4 @@ services: - "8000:8000" volumes: - "files:/app/data" - - "./config.prod.toml:/app/config.toml" \ No newline at end of file + - "./config.prod.toml:/app/config.toml" diff --git a/docker-compose.yml b/docker-compose.yml index e294055..4c32e9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,4 +9,4 @@ services: ports: - "3366:3306" volumes: - - "db:/var/lib/mysql" \ No newline at end of file + - "db:/var/lib/mysql" diff --git a/migrations/20241004152857_admin.sql b/migrations/20241004152857_admin.sql new file mode 100644 index 0000000..a6f70b5 --- /dev/null +++ b/migrations/20241004152857_admin.sql @@ -0,0 +1,2 @@ +alter table users + add column is_admin bit(1) not null; \ No newline at end of file diff --git a/src/auth/nip98.rs b/src/auth/nip98.rs index 5e05f1a..6fdeb7c 100644 --- a/src/auth/nip98.rs +++ b/src/auth/nip98.rs @@ -18,7 +18,7 @@ impl<'r> FromRequest<'r> for Nip98Auth { type Error = &'static str; async fn from_request(request: &'r Request<'_>) -> Outcome { - return if let Some(auth) = request.headers().get_one("authorization") { + if let Some(auth) = request.headers().get_one("authorization") { if auth.starts_with("Nostr ") { let event = if let Ok(j) = BASE64_STANDARD.decode(&auth[6..]) { if let Ok(ev) = Event::from_json(j) { @@ -103,6 +103,6 @@ impl<'r> FromRequest<'r> for Nip98Auth { } } else { Outcome::Error((Status::new(403), "Auth header not found")) - }; + } } } diff --git a/src/bin/main.rs b/src/bin/main.rs index 34e788e..948f32f 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -62,7 +62,8 @@ async fn main() -> Result<(), Error> { ) .attach(CORS) .attach(Shield::new()) // disable - .mount("/", routes![root, get_blob, head_blob]); + .mount("/", routes![root, get_blob, head_blob]) + .mount("/admin", routes::admin_routes()); #[cfg(feature = "analytics")] { diff --git a/src/db.rs b/src/db.rs index 6eb05c0..f3aeb65 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,10 +1,11 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -use sqlx::{Error, Executor, FromRow, Row}; use sqlx::migrate::MigrateError; +use sqlx::{Error, Executor, FromRow, Row}; #[derive(Clone, FromRow, Default, Serialize)] pub struct FileUpload { + #[serde(with = "hex")] pub id: Vec, pub name: String, pub size: u64, @@ -20,11 +21,13 @@ pub struct FileUpload { pub labels: Vec, } -#[derive(Clone, FromRow)] +#[derive(Clone, FromRow, Serialize)] pub struct User { pub id: u64, + #[serde(with = "hex")] pub pubkey: Vec, pub created: DateTime, + pub is_admin: bool, } #[cfg(feature = "labels")] @@ -50,7 +53,7 @@ impl FileLabel { #[derive(Clone)] pub struct Database { - pool: sqlx::pool::Pool, + pub(crate) pool: sqlx::pool::Pool, } impl Database { @@ -78,6 +81,13 @@ impl Database { } } + pub async fn get_user(&self, pubkey: &Vec) -> Result { + sqlx::query_as("select * from users where pubkey = ?") + .bind(pubkey) + .fetch_one(&self.pool) + .await + } + pub async fn get_user_id(&self, pubkey: &Vec) -> Result { sqlx::query("select id from users where pubkey = ?") .bind(pubkey) @@ -178,8 +188,7 @@ impl Database { "select count(uploads.id) from uploads, users, user_uploads \ where users.pubkey = ? \ and users.id = user_uploads.user_id \ - and user_uploads.file = uploads.id \ - order by uploads.created desc") + and user_uploads.file = uploads.id") .bind(pubkey) .fetch_one(&self.pool) .await? diff --git a/src/routes/admin.rs b/src/routes/admin.rs new file mode 100644 index 0000000..61e5b9d --- /dev/null +++ b/src/routes/admin.rs @@ -0,0 +1,125 @@ +use crate::auth::nip98::Nip98Auth; +use crate::db::{Database, FileUpload, User}; +use crate::routes::{Nip94Event, PagedResult}; +use rocket::serde::json::Json; +use rocket::serde::Serialize; +use rocket::{routes, Responder, Route, State}; +use sqlx::{Error, Row}; +use crate::settings::Settings; + +pub fn admin_routes() -> Vec { + routes![admin_list_files, admin_get_self] +} + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct AdminResponseBase +{ + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Responder)] +enum AdminResponse +{ + #[response(status = 500)] + GenericError(Json>), + + #[response(status = 200)] + Ok(Json>), +} + +impl AdminResponse +{ + pub fn error(msg: &str) -> Self { + Self::GenericError(Json(AdminResponseBase { + status: "error".to_string(), + message: Some(msg.to_string()), + data: None, + })) + } + + pub fn success(msg: T) -> Self { + Self::Ok(Json(AdminResponseBase { + status: "success".to_string(), + message: None, + data: Some(msg), + })) + } +} + +#[rocket::get("/self")] +async fn admin_get_self( + auth: Nip98Auth, + db: &State, +) -> AdminResponse { + let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); + match db.get_user(&pubkey_vec).await { + Ok(user) => AdminResponse::success(user), + Err(_) => { + AdminResponse::error("User not found") + } + } +} + +#[rocket::get("/files?&")] +async fn admin_list_files( + auth: Nip98Auth, + page: u32, + count: u32, + db: &State, + settings: &State, +) -> AdminResponse> { + let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); + let server_count = count.min(5_000).max(1); + + let user = match db.get_user(&pubkey_vec).await { + Ok(user) => user, + Err(_) => { + return AdminResponse::error("User not found") + } + }; + + if !user.is_admin { + return AdminResponse::error("User is not an admin"); + } + match db + .list_all_files(page * server_count, server_count) + .await + { + Ok((files, count)) => AdminResponse::success(PagedResult { + count: files.len() as u32, + page, + total: count as u32, + files: files + .iter() + .map(|f| Nip94Event::from_upload(settings, f)) + .collect(), + }), + Err(e) => AdminResponse::error(&format!("Could not list files: {}", e)), + } +} + +impl Database { + pub async fn list_all_files(&self, offset: u32, limit: u32) -> Result<(Vec, i64), Error> { + let results: Vec = sqlx::query_as( + "select u.* \ + from uploads u \ + order by u.created desc \ + limit ? offset ?", + ) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await?; + let count: i64 = sqlx::query( + "select count(u.id) from uploads u") + .fetch_one(&self.pool) + .await? + .try_get(0)?; + Ok((results, count)) + } +} \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1d9aeb8..5047da1 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use crate::db::{Database, FileUpload}; use crate::filesystem::FileStore; +pub use crate::routes::admin::admin_routes; #[cfg(feature = "blossom")] pub use crate::routes::blossom::blossom_routes; #[cfg(feature = "nip96")] @@ -22,6 +23,8 @@ mod blossom; #[cfg(feature = "nip96")] mod nip96; +mod admin; + pub struct FilePayload { pub file: File, pub info: FileUpload, @@ -35,6 +38,15 @@ struct Nip94Event { pub tags: Vec>, } +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct PagedResult { + pub count: u32, + pub page: u32, + pub total: u32, + pub files: Vec, +} + impl Nip94Event { pub fn from_upload(settings: &Settings, upload: &FileUpload) -> Self { let hex_id = hex::encode(&upload.id); diff --git a/src/routes/nip96.rs b/src/routes/nip96.rs index 7fb759c..9d5ce48 100644 --- a/src/routes/nip96.rs +++ b/src/routes/nip96.rs @@ -15,7 +15,7 @@ use rocket::{routes, FromForm, Responder, Route, State}; use crate::auth::nip98::Nip98Auth; use crate::db::{Database, FileUpload}; use crate::filesystem::FileStore; -use crate::routes::{delete_file, Nip94Event}; +use crate::routes::{delete_file, Nip94Event, PagedResult}; use crate::settings::Settings; use crate::webhook::Webhook; @@ -66,15 +66,6 @@ struct Nip96MediaTransformations { pub video: Option>, } -#[derive(Serialize, Default)] -#[serde(crate = "rocket::serde")] -struct Nip96FileListResults { - pub count: u32, - pub page: u32, - pub total: u32, - pub files: Vec, -} - #[derive(Responder)] enum Nip96Response { #[response(status = 500)] @@ -84,11 +75,11 @@ enum Nip96Response { UploadResult(Json), #[response(status = 200)] - FileList(Json), + FileList(Json>), } impl Nip96Response { - fn error(msg: &str) -> Self { + pub(crate)fn error(msg: &str) -> Self { Nip96Response::GenericError(Json(Nip96UploadResult { status: "error".to_string(), message: Some(msg.to_string()), @@ -295,7 +286,7 @@ async fn list_files( .list_files(&pubkey_vec, page * server_count, server_count) .await { - Ok((files, total)) => Nip96Response::FileList(Json(Nip96FileListResults { + Ok((files, total)) => Nip96Response::FileList(Json(PagedResult { count: server_count, page, total: total as u32, diff --git a/ui_src/.vscode/extensions.json b/ui_src/.vscode/extensions.json index 875f63d..daaa5ee 100644 --- a/ui_src/.vscode/extensions.json +++ b/ui_src/.vscode/extensions.json @@ -1,3 +1,7 @@ { - "recommendations": ["arcanis.vscode-zipfs", "dbaeumer.vscode-eslint"] + "recommendations": [ + "arcanis.vscode-zipfs", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] } diff --git a/ui_src/.yarn/sdks/prettier/bin/prettier.cjs b/ui_src/.yarn/sdks/prettier/bin/prettier.cjs new file mode 100755 index 0000000..aa88884 --- /dev/null +++ b/ui_src/.yarn/sdks/prettier/bin/prettier.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const { existsSync } = require(`fs`); +const { createRequire, register } = require(`module`); +const { resolve } = require(`path`); +const { pathToFileURL } = require(`url`); + +const relPnpApiPath = "../../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require prettier/bin/prettier.cjs + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? (exports) => absRequire(absUserWrapperPath)(exports) + : (exports) => exports; + +// Defer to the real prettier/bin/prettier.cjs your application uses +module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`)); diff --git a/ui_src/.yarn/sdks/prettier/index.cjs b/ui_src/.yarn/sdks/prettier/index.cjs new file mode 100644 index 0000000..c5af1a7 --- /dev/null +++ b/ui_src/.yarn/sdks/prettier/index.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const { existsSync } = require(`fs`); +const { createRequire, register } = require(`module`); +const { resolve } = require(`path`); +const { pathToFileURL } = require(`url`); + +const relPnpApiPath = "../../../.pnp.cjs"; + +const absPnpApiPath = resolve(__dirname, relPnpApiPath); +const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); +const absRequire = createRequire(absPnpApiPath); + +const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); +const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); + +if (existsSync(absPnpApiPath)) { + if (!process.versions.pnp) { + // Setup the environment to be able to require prettier + require(absPnpApiPath).setup(); + if (isPnpLoaderEnabled && register) { + register(pathToFileURL(absPnpLoaderPath)); + } + } +} + +const wrapWithUserWrapper = existsSync(absUserWrapperPath) + ? (exports) => absRequire(absUserWrapperPath)(exports) + : (exports) => exports; + +// Defer to the real prettier your application uses +module.exports = wrapWithUserWrapper(absRequire(`prettier`)); diff --git a/ui_src/.yarn/sdks/prettier/package.json b/ui_src/.yarn/sdks/prettier/package.json new file mode 100644 index 0000000..cf1b58d --- /dev/null +++ b/ui_src/.yarn/sdks/prettier/package.json @@ -0,0 +1,7 @@ +{ + "name": "prettier", + "version": "3.3.3-sdk", + "main": "./index.cjs", + "type": "commonjs", + "bin": "./bin/prettier.cjs" +} diff --git a/ui_src/src/upload/admin.ts b/ui_src/src/upload/admin.ts new file mode 100644 index 0000000..6946989 --- /dev/null +++ b/ui_src/src/upload/admin.ts @@ -0,0 +1,87 @@ +import { base64 } from "@scure/base"; +import { throwIfOffline } from "@snort/shared"; +import { EventKind, EventPublisher, NostrEvent } from "@snort/system"; + +export class Route96 { + constructor( + readonly url: string, + readonly publisher: EventPublisher, + ) { + this.url = new URL(this.url).toString(); + } + + async getSelf() { + const rsp = await this.#req("/admin/self", "GET"); + const data = + await this.#handleResponse>(rsp); + return data; + } + + async listFiles(page = 0, count = 10) { + const rsp = await this.#req( + `/admin/files?page=${page}&count=${count}`, + "GET", + ); + const data = await this.#handleResponse(rsp); + return { + ...data, + ...data.data, + files: data.data.files, + }; + } + + async #handleResponse(rsp: Response) { + if (rsp.ok) { + return (await rsp.json()) as T; + } else { + const text = await rsp.text(); + try { + const obj = JSON.parse(text) as AdminResponseBase; + throw new Error(obj.message); + } catch { + throw new Error(`Upload failed: ${text}`); + } + } + } + + async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) { + throwIfOffline(); + const auth = async (url: string, method: string) => { + const auth = await this.publisher.generic((eb) => { + return eb + .kind(EventKind.HttpAuthentication) + .tag(["u", url]) + .tag(["method", method]); + }); + return `Nostr ${base64.encode( + new TextEncoder().encode(JSON.stringify(auth)), + )}`; + }; + + const u = `${this.url}${path}`; + return await fetch(u, { + method, + body, + headers: { + accept: "application/json", + authorization: await auth(u, method), + }, + }); + } +} + +export interface AdminResponseBase { + status: string; + message?: string; +} + +export type AdminResponse = AdminResponseBase & { + data: T; +}; + +export type AdminResponseFileList = AdminResponse<{ + total: number; + page: number; + count: number; + files: Array; +}>; diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx index fa9cebb..47a6055 100644 --- a/ui_src/src/views/upload.tsx +++ b/ui_src/src/views/upload.tsx @@ -6,21 +6,25 @@ import { Blossom } from "../upload/blossom"; import useLogin from "../hooks/login"; import usePublisher from "../hooks/publisher"; import { Nip96, Nip96FileList } from "../upload/nip96"; +import { Route96 } from "../upload/admin"; export default function Upload() { const [type, setType] = useState<"blossom" | "nip96">("nip96"); const [noCompress, setNoCompress] = useState(false); const [toUpload, setToUpload] = useState(); + const [self, setSelf] = useState<{ is_admin: boolean }>(); const [error, setError] = useState(); const [results, setResults] = useState>([]); const [listedFiles, setListedFiles] = useState(); + const [adminListedFiles, setAdminListedFiles] = useState(); const [listedPage, setListedPage] = useState(0); + const [adminListedPage, setAdminListedPage] = useState(0); const login = useLogin(); const pub = usePublisher(); const url = `${location.protocol}//${location.host}`; - //const url = "https://files.v0l.io"; + //const url = "http://localhost:8000"; async function doUpload() { if (!pub) return; if (!toUpload) return; @@ -67,10 +71,39 @@ export default function Upload() { } } + async function listAllUploads(n: number) { + if (!pub) return; + try { + setError(undefined); + const uploader = new Route96(url, pub); + const result = await uploader.listFiles(n, 12); + 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"); + } + } + } + useEffect(() => { listUploads(listedPage); }, [listedPage]); + useEffect(() => { + listAllUploads(adminListedPage); + }, [adminListedPage]); + + useEffect(() => { + if (pub && !self) { + const r96 = new Route96(url, pub); + r96.getSelf().then((v) => setSelf(v.data)); + } + }, [pub, self]); + return (

@@ -121,6 +154,7 @@ export default function Upload() { + {listedFiles && ( setListedPage(x)} /> )} + + {self?.is_admin && ( + <> +

Admin File List:

+ + {adminListedFiles && ( + setAdminListedPage(x)} + /> + )} + + )} {error && {error}}
         {JSON.stringify(results, undefined, 2)}