From c4aae42fcd61e8758d01f5843d101255f24208d5 Mon Sep 17 00:00:00 2001 From: kieran Date: Wed, 1 May 2024 15:00:43 +0100 Subject: [PATCH] Nip96 --- config.toml | 8 +- src/{auth.rs => auth/blossom.rs} | 12 +- src/auth/mod.rs | 2 + src/auth/nip98.rs | 106 +++++++++++++ src/blob.rs | 11 +- src/cors.rs | 4 +- src/db.rs | 14 +- src/filesystem.rs | 24 +-- src/main.rs | 17 +- src/routes.rs | 262 ------------------------------- src/routes/blossom.rs | 164 +++++++++++++++++++ src/routes/mod.rs | 138 ++++++++++++++++ src/routes/nip96.rs | 215 +++++++++++++++++++++++++ src/settings.rs | 6 + 14 files changed, 685 insertions(+), 298 deletions(-) rename src/{auth.rs => auth/blossom.rs} (92%) create mode 100644 src/auth/mod.rs create mode 100644 src/auth/nip98.rs delete mode 100644 src/routes.rs create mode 100644 src/routes/blossom.rs create mode 100644 src/routes/mod.rs create mode 100644 src/routes/nip96.rs diff --git a/config.toml b/config.toml index fbf1c28..8f76371 100644 --- a/config.toml +++ b/config.toml @@ -5,4 +5,10 @@ listen = "127.0.0.1:8000" database = "mysql://root:root@localhost:3366/void_cat" # Directory to store uploads -storage_dir = "./data" \ No newline at end of file +storage_dir = "./data" + +# Maximum support filesize for uploading +max_upload_bytes = 104857600 + +# Public facing url +public_url = "http://localhost:8000" \ No newline at end of file diff --git a/src/auth.rs b/src/auth/blossom.rs similarity index 92% rename from src/auth.rs rename to src/auth/blossom.rs index 8a89ee0..1403400 100644 --- a/src/auth.rs +++ b/src/auth/blossom.rs @@ -1,9 +1,9 @@ use base64::prelude::*; use log::info; use nostr::{Event, JsonUtil, Kind, Tag, Timestamp}; -use rocket::{async_trait, Request}; use rocket::http::Status; use rocket::request::{FromRequest, Outcome}; +use rocket::{async_trait, Request}; pub struct BlossomAuth { pub content_type: Option, @@ -56,10 +56,12 @@ impl<'r> FromRequest<'r> for BlossomAuth { info!("{}", event.as_json()); Outcome::Success(BlossomAuth { event, - content_type: request.headers().iter().find_map(|h| if h.name == "content-type" { - Some(h.value.to_string()) - } else { - None + content_type: request.headers().iter().find_map(|h| { + if h.name == "content-type" { + Some(h.value.to_string()) + } else { + None + } }), }) } else { diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..dad6b17 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod blossom; +pub mod nip98; diff --git a/src/auth/nip98.rs b/src/auth/nip98.rs new file mode 100644 index 0000000..2450baa --- /dev/null +++ b/src/auth/nip98.rs @@ -0,0 +1,106 @@ +use std::ops::Sub; +use std::time::Duration; + +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use log::info; +use nostr::{Event, JsonUtil, Kind, Timestamp}; +use rocket::http::uri::{Absolute, Uri}; +use rocket::http::Status; +use rocket::request::{FromRequest, Outcome}; +use rocket::{async_trait, Request}; + +pub struct Nip98Auth { + pub content_type: Option, + pub event: Event, +} + +#[async_trait] +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 auth.starts_with("Nostr ") { + let event = if let Ok(j) = BASE64_STANDARD.decode(auth[6..].to_string()) { + if let Ok(ev) = Event::from_json(j) { + ev + } else { + return Outcome::Error((Status::new(403), "Invalid nostr event")); + } + } else { + return Outcome::Error((Status::new(403), "Invalid auth string")); + }; + + if event.kind != Kind::HttpAuth { + return Outcome::Error((Status::new(401), "Wrong event kind")); + } + if event.created_at > Timestamp::now() { + return Outcome::Error(( + Status::new(401), + "Created timestamp is in the future", + )); + } + if event.created_at < Timestamp::now().sub(Duration::from_secs(60)) { + return Outcome::Error((Status::new(401), "Created timestamp is too old")); + } + + // check url tag + if let Some(url) = event.tags.iter().find_map(|t| { + let vec = t.as_vec(); + if vec[0] == "u" { + Some(vec[1].clone()) + } else { + None + } + }) { + if let Ok(u_req) = Uri::parse::(&url) { + if request.uri().path() != u_req.absolute().unwrap().path() { + return Outcome::Error((Status::new(401), "U tag does not match")); + } + } else { + return Outcome::Error((Status::new(401), "Invalid U tag")); + } + } else { + return Outcome::Error((Status::new(401), "Missing url tag")); + } + + // check method tag + if let Some(method) = event.tags.iter().find_map(|t| { + let vec = t.as_vec(); + if vec[0] == "method" { + Some(vec[1].clone()) + } else { + None + } + }) { + if request.method().to_string() != *method { + return Outcome::Error((Status::new(401), "Method tag incorrect")); + } + } else { + return Outcome::Error((Status::new(401), "Missing method tag")); + } + + if let Err(_err) = event.verify() { + return Outcome::Error((Status::new(401), "Event signature invalid")); + } + + info!("{}", event.as_json()); + Outcome::Success(Nip98Auth { + event, + content_type: request.headers().iter().find_map(|h| { + if h.name == "content-type" { + Some(h.value.to_string()) + } else { + None + } + }), + }) + } else { + Outcome::Error((Status::new(403), "Auth scheme must be Nostr")) + } + } else { + Outcome::Error((Status::new(403), "Auth header not found")) + }; + } +} diff --git a/src/blob.rs b/src/blob.rs index 22e55ba..4573198 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -13,14 +13,15 @@ pub struct BlobDescriptor { pub created: u64, } -impl From<&FileUpload> for BlobDescriptor { - fn from(value: &FileUpload) -> Self { +impl BlobDescriptor { + pub fn from_upload(value: &FileUpload, public_url: &String) -> Self { + let id_hex = hex::encode(&value.id); Self { - url: "".to_string(), - sha256: hex::encode(&value.id), + url: format!("{}/{}", public_url, &id_hex), + sha256: id_hex, size: value.size, mime_type: Some(value.mime_type.clone()), created: value.created.timestamp() as u64, } } -} \ No newline at end of file +} diff --git a/src/cors.rs b/src/cors.rs index b54fec1..e450853 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -1,7 +1,7 @@ -use std::io::Cursor; use rocket::fairing::{Fairing, Info, Kind}; use rocket::http::{Header, Method, Status}; -use rocket::{Data, Request, Response}; +use rocket::{Request, Response}; +use std::io::Cursor; pub struct CORS; diff --git a/src/db.rs b/src/db.rs index c8b13dc..b10e9e6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; -use sqlx::{Error, FromRow, Row}; use sqlx::migrate::MigrateError; +use sqlx::{Error, FromRow, Row}; #[derive(Clone, FromRow)] pub struct FileUpload { @@ -38,7 +38,7 @@ impl Database { .fetch_one(&self.pool) .await? .try_get(0), - Some(res) => res.try_get(0) + Some(res) => res.try_get(0), } } @@ -78,10 +78,12 @@ impl Database { } pub async fn list_files(&self, pubkey: &Vec) -> Result, Error> { - let results: Vec = sqlx::query_as("select * from uploads where user_id = (select id from users where pubkey = ?)") - .bind(&pubkey) - .fetch_all(&self.pool) - .await?; + let results: Vec = sqlx::query_as( + "select * from uploads where user_id = (select id from users where pubkey = ?)", + ) + .bind(&pubkey) + .fetch_all(&self.pool) + .await?; Ok(results) } } diff --git a/src/filesystem.rs b/src/filesystem.rs index e390060..9547d71 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -1,16 +1,14 @@ use std::env::temp_dir; -use std::fs; -use std::io::SeekFrom; +use std::io::{SeekFrom}; use std::path::{Path, PathBuf}; +use std::{fs}; use anyhow::Error; use log::info; -use rocket::data::DataStream; use sha2::{Digest, Sha256}; use tokio::fs::File; -use tokio::io::{AsyncReadExt, AsyncSeekExt, BufWriter}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt}; -use crate::db::Database; use crate::settings::Settings; #[derive(Clone)] @@ -37,14 +35,20 @@ impl FileStore { } /// Store a new file - pub async fn put(&self, stream: DataStream<'_>) -> Result { + pub async fn put(&self, mut stream: TStream) -> Result + where + TStream: AsyncRead + Unpin, + { let random_id = uuid::Uuid::new_v4(); let tmp_path = FileStore::map_temp(random_id); let mut file = File::options() - .create(true).write(true).read(true) - .open(tmp_path.clone()).await?; - let n = stream.stream_to(&mut BufWriter::new(&mut file)).await?; + .create(true) + .write(true) + .read(true) + .open(tmp_path.clone()) + .await?; + let n = tokio::io::copy(&mut stream, &mut file).await?; info!("File saved to temp path: {}", tmp_path.to_str().unwrap()); let hash = FileStore::hash_file(&mut file).await?; @@ -55,7 +59,7 @@ impl FileStore { Err(Error::from(e)) } else { Ok(FileSystemResult { - size: n.written, + size: n, sha256: hash, path: dst_path, }) diff --git a/src/main.rs b/src/main.rs index 22a25e4..7782a81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,21 @@ -use crate::db::Database; -use crate::filesystem::FileStore; -use crate::settings::Settings; use anyhow::Error; use config::Config; use log::{error, info}; -use rocket::fairing::{Fairing, Info}; use rocket::routes; + use crate::cors::CORS; +use crate::db::Database; +use crate::filesystem::FileStore; +use crate::routes::{get_blob, head_blob, root}; +use crate::settings::Settings; mod auth; mod blob; +mod cors; mod db; mod filesystem; mod routes; mod settings; -mod cors; #[rocket::main] async fn main() -> Result<(), Error> { @@ -37,7 +38,9 @@ async fn main() -> Result<(), Error> { .manage(settings.clone()) .manage(db.clone()) .attach(CORS) - .mount("/", routes::all()) + .mount("/", routes::blossom_routes()) + .mount("/", routes::nip96_routes()) + .mount("/", routes![root, get_blob, head_blob]) .launch() .await; @@ -47,4 +50,4 @@ async fn main() -> Result<(), Error> { } else { Ok(()) } -} \ No newline at end of file +} diff --git a/src/routes.rs b/src/routes.rs deleted file mode 100644 index a6bb458..0000000 --- a/src/routes.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::fs; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; - -use chrono::Utc; -use log::{error, info}; -use nostr::{JsonUtil, Tag, TagKind}; -use nostr::prelude::hex; -use rocket::{async_trait, Data, Request, Route, routes, State, uri}; -use rocket::data::ToByteUnit; -use rocket::fs::NamedFile; -use rocket::http::{ContentType, Header, Status}; -use rocket::http::hyper::header::CONTENT_DISPOSITION; -use rocket::request::{FromRequest, Outcome}; -use rocket::response::Responder; -use rocket::response::status::NotFound; -use rocket::serde::json::Json; -use serde::{Deserialize, Serialize}; - -use crate::auth::BlossomAuth; -use crate::blob::BlobDescriptor; -use crate::db::{Database, FileUpload}; -use crate::filesystem::FileStore; -use crate::routes::BlossomResponse::BlobDescriptorList; - -pub fn all() -> Vec { - routes![root, get_blob, head_blob, delete_blob, upload, list_files] -} - -#[derive(Serialize, Deserialize)] -struct BlossomError { - pub message: String, -} - -impl BlossomError { - pub fn new(msg: String) -> Self { - Self { message: msg } - } -} - -struct BlossomFile { - pub file: File, - pub info: FileUpload, -} - -impl<'r> Responder<'r, 'static> for BlossomFile { - fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> { - let mut response = self.file.respond_to(request)?; - if let Ok(ct) = ContentType::from_str(&self.info.mime_type) { - response.set_header(ct); - } - response.set_header(Header::new("content-disposition", format!("inline; filename=\"{}\"", self.info.name))); - Ok(response) - } -} - -#[derive(Responder)] -enum BlossomResponse { - #[response(status = 403)] - Unauthorized(Json), - - #[response(status = 500)] - GenericError(Json), - - #[response(status = 200)] - File(BlossomFile), - - #[response(status = 200)] - BlobDescriptor(Json), - - #[response(status = 200)] - BlobDescriptorList(Json>), - - StatusOnly(Status), -} - -impl BlossomResponse { - pub fn error(msg: impl Into) -> Self { - Self::GenericError(Json(BlossomError::new(msg.into()))) - } -} - -fn check_method(event: &nostr::Event, method: &str) -> bool { - if let Some(t) = event.tags.iter().find_map(|t| match t { - Tag::Hashtag(tag) => Some(tag), - _ => None, - }) { - return t == method; - } - false -} - -#[rocket::get("/")] -async fn root() -> Result { - if let Ok(f) = NamedFile::open("./ui/index.html").await { - Ok(f) - } else { - Err(Status::InternalServerError) - } -} - -#[rocket::get("/")] -async fn get_blob(sha256: &str, fs: &State, db: &State) -> BlossomResponse { - let sha256 = if sha256.contains(".") { - sha256.split('.').next().unwrap() - } else { - sha256 - }; - let id = if let Ok(i) = hex::decode(sha256) { - i - } else { - return BlossomResponse::error("Invalid file id"); - }; - - if id.len() != 32 { - return BlossomResponse::error("Invalid file id"); - } - if let Ok(Some(info)) = db.get_file(&id).await { - if let Ok(f) = File::open(fs.get(&id)) { - return BlossomResponse::File(BlossomFile { - file: f, - info, - }); - } - } - BlossomResponse::StatusOnly(Status::NotFound) -} - -#[rocket::head("/")] -async fn head_blob(sha256: &str, fs: &State) -> BlossomResponse { - let sha256 = if sha256.contains(".") { - sha256.split('.').next().unwrap() - } else { - sha256 - }; - let id = if let Ok(i) = hex::decode(sha256) { - i - } else { - return BlossomResponse::error("Invalid file id"); - }; - - if id.len() != 32 { - return BlossomResponse::error("Invalid file id"); - } - if fs.get(&id).exists() { - BlossomResponse::StatusOnly(Status::Ok) - } else { - BlossomResponse::StatusOnly(Status::NotFound) - } -} - -#[rocket::delete("/")] -async fn delete_blob(sha256: &str, auth: BlossomAuth, fs: &State, db: &State) -> BlossomResponse { - let sha256 = if sha256.contains(".") { - sha256.split('.').next().unwrap() - } else { - sha256 - }; - let id = if let Ok(i) = hex::decode(sha256) { - i - } else { - return BlossomResponse::error("Invalid file id"); - }; - - if id.len() != 32 { - return BlossomResponse::error("Invalid file id"); - } - if !check_method(&auth.event, "delete") { - return BlossomResponse::error("Invalid request method tag"); - } - if let Ok(Some(info)) = db.get_file(&id).await { - let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); - let user = match db.get_user_id(&pubkey_vec).await { - Ok(u) => u, - Err(_e) => return BlossomResponse::error("User not found") - }; - if user != info.user_id { - return BlossomResponse::error("You dont own this file, you cannot delete it"); - } - if let Err(e) = db.delete_file(&id).await { - return BlossomResponse::error(format!("Failed to delete (db): {}", e)); - } - if let Err(e) = fs::remove_file(fs.get(&id)) { - return BlossomResponse::error(format!("Failed to delete (fs): {}", e)); - } - BlossomResponse::StatusOnly(Status::Ok) - } else { - BlossomResponse::StatusOnly(Status::NotFound) - } -} - -#[rocket::put("/upload", data = "")] -async fn upload(auth: BlossomAuth, fs: &State, db: &State, data: Data<'_>) - -> BlossomResponse { - if !check_method(&auth.event, "upload") { - return BlossomResponse::error("Invalid request method tag"); - } - - let name = auth.event.tags.iter().find_map(|t| match t { - Tag::Name(s) => Some(s.clone()), - _ => None - }); - let size = auth.event.tags.iter().find_map(|t| { - let values = t.as_vec(); - if values.len() == 2 && values[0] == "size" { - Some(values[1].parse::().unwrap()) - } else { - None - } - }); - if size.is_none() { - return BlossomResponse::error("Invalid request, no size tag"); - } - match fs.put(data.open(8.gigabytes())).await { - Ok(blob) => { - let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); - let user_id = match db.upsert_user(&pubkey_vec).await { - Ok(u) => u, - Err(e) => return BlossomResponse::error(format!("Failed to save file (db): {}", e)) - }; - let f = FileUpload { - id: blob.sha256, - user_id: user_id as u64, - name: name.unwrap_or("".to_string()), - size: blob.size, - mime_type: auth.content_type.unwrap_or("application/octet-stream".to_string()), - created: Utc::now(), - }; - if let Err(e) = db.add_file(&f).await { - error!("{:?}", e); - BlossomResponse::error(format!("Error saving file (db): {}", e)) - } else { - BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from(&f))) - } - } - Err(e) => { - error!("{:?}", e); - BlossomResponse::error(format!("Error saving file (disk): {}", e)) - } - } -} - -#[rocket::get("/list/")] -async fn list_files( - db: &State, - pubkey: &str, -) -> BlossomResponse { - let id = if let Ok(i) = hex::decode(pubkey) { - i - } else { - return BlossomResponse::error("invalid pubkey"); - }; - match db.list_files(&id).await { - Ok(files) => BlobDescriptorList(Json(files.iter() - .map(|f| BlobDescriptor::from(f)) - .collect()) - ), - Err(e) => BlossomResponse::error(format!("Could not list files: {}", e)) - } -} \ No newline at end of file diff --git a/src/routes/blossom.rs b/src/routes/blossom.rs new file mode 100644 index 0000000..ff8b798 --- /dev/null +++ b/src/routes/blossom.rs @@ -0,0 +1,164 @@ +use chrono::Utc; +use log::{error}; +use nostr::prelude::hex; +use nostr::{Tag}; +use rocket::data::{ByteUnit}; + +use rocket::http::{Status}; +use rocket::response::Responder; +use rocket::serde::json::Json; +use rocket::{routes, Data, Route, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::blossom::BlossomAuth; +use crate::blob::BlobDescriptor; +use crate::db::{Database, FileUpload}; +use crate::filesystem::FileStore; +use crate::routes::{delete_file}; +use crate::settings::Settings; + +#[derive(Serialize, Deserialize)] +struct BlossomError { + pub message: String, +} + +pub fn blossom_routes() -> Vec { + routes![delete_blob, upload, list_files] +} + +impl BlossomError { + pub fn new(msg: String) -> Self { + Self { message: msg } + } +} + +#[derive(Responder)] +enum BlossomResponse { + #[response(status = 500)] + GenericError(Json), + + #[response(status = 200)] + BlobDescriptor(Json), + + #[response(status = 200)] + BlobDescriptorList(Json>), + + StatusOnly(Status), +} + +impl BlossomResponse { + pub fn error(msg: impl Into) -> Self { + Self::GenericError(Json(BlossomError::new(msg.into()))) + } +} + +fn check_method(event: &nostr::Event, method: &str) -> bool { + if let Some(t) = event.tags.iter().find_map(|t| match t { + Tag::Hashtag(tag) => Some(tag), + _ => None, + }) { + return t == method; + } + false +} + +#[rocket::delete("/")] +async fn delete_blob( + sha256: &str, + auth: BlossomAuth, + fs: &State, + db: &State, +) -> BlossomResponse { + match delete_file(sha256, &auth.event, fs, db).await { + Ok(()) => BlossomResponse::StatusOnly(Status::Ok), + Err(e) => BlossomResponse::error(format!("Failed to delete file: {}", e)), + } +} + +#[rocket::put("/upload", data = "")] +async fn upload( + auth: BlossomAuth, + fs: &State, + db: &State, + settings: &State, + data: Data<'_>, +) -> BlossomResponse { + if !check_method(&auth.event, "upload") { + return BlossomResponse::error("Invalid request method tag"); + } + + let name = auth.event.tags.iter().find_map(|t| match t { + Tag::Name(s) => Some(s.clone()), + _ => None, + }); + let size = auth.event.tags.iter().find_map(|t| { + let values = t.as_vec(); + if values.len() == 2 && values[0] == "size" { + Some(values[1].parse::().unwrap()) + } else { + None + } + }); + if size.is_none() { + return BlossomResponse::error("Invalid request, no size tag"); + } + match fs + .put(data.open(ByteUnit::from(settings.max_upload_bytes))) + .await + { + Ok(blob) => { + let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); + let user_id = match db.upsert_user(&pubkey_vec).await { + Ok(u) => u, + Err(e) => { + return BlossomResponse::error(format!("Failed to save file (db): {}", e)) + } + }; + let f = FileUpload { + id: blob.sha256, + user_id, + name: name.unwrap_or("".to_string()), + size: blob.size, + mime_type: auth + .content_type + .unwrap_or("application/octet-stream".to_string()), + created: Utc::now(), + }; + if let Err(e) = db.add_file(&f).await { + error!("{:?}", e); + BlossomResponse::error(format!("Error saving file (db): {}", e)) + } else { + BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from_upload( + &f, + &settings.public_url, + ))) + } + } + Err(e) => { + error!("{:?}", e); + BlossomResponse::error(format!("Error saving file (disk): {}", e)) + } + } +} + +#[rocket::get("/list/")] +async fn list_files( + db: &State, + settings: &State, + pubkey: &str, +) -> BlossomResponse { + let id = if let Ok(i) = hex::decode(pubkey) { + i + } else { + return BlossomResponse::error("invalid pubkey"); + }; + match db.list_files(&id).await { + Ok(files) => BlossomResponse::BlobDescriptorList(Json( + files + .iter() + .map(|f| BlobDescriptor::from_upload(&f, &settings.public_url)) + .collect(), + )), + Err(e) => BlossomResponse::error(format!("Could not list files: {}", e)), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..25f743f --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,138 @@ +use std::fs; +use std::fs::File; +use std::str::FromStr; + +use anyhow::Error; +use nostr::Event; +use rocket::fs::NamedFile; +use rocket::http::{ContentType, Header, Status}; +use rocket::response::Responder; +use rocket::{Request, State}; + +use crate::db::{Database, FileUpload}; +use crate::filesystem::FileStore; +pub use crate::routes::blossom::blossom_routes; +pub use crate::routes::nip96::nip96_routes; + +mod blossom; +mod nip96; + +pub struct FilePayload { + pub file: File, + pub info: FileUpload, +} + +impl<'r> Responder<'r, 'static> for FilePayload { + fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> { + let mut response = self.file.respond_to(request)?; + if let Ok(ct) = ContentType::from_str(&self.info.mime_type) { + response.set_header(ct); + } + response.set_header(Header::new( + "content-disposition", + format!("inline; filename=\"{}\"", self.info.name), + )); + Ok(response) + } +} + +async fn delete_file( + sha256: &str, + auth: &Event, + fs: &FileStore, + db: &Database, +) -> Result<(), Error> { + let sha256 = if sha256.contains(".") { + sha256.split('.').next().unwrap() + } else { + sha256 + }; + let id = if let Ok(i) = hex::decode(sha256) { + i + } else { + return Err(Error::msg("Invalid file id")); + }; + + if id.len() != 32 { + return Err(Error::msg("Invalid file id")); + } + if let Ok(Some(info)) = db.get_file(&id).await { + let pubkey_vec = auth.pubkey.to_bytes().to_vec(); + let user = match db.get_user_id(&pubkey_vec).await { + Ok(u) => u, + Err(_e) => return Err(Error::msg("User not found")), + }; + if user != info.user_id { + return Err(Error::msg("You dont own this file, you cannot delete it")); + } + if let Err(e) = db.delete_file(&id).await { + return Err(Error::msg(format!("Failed to delete (db): {}", e))); + } + if let Err(e) = fs::remove_file(fs.get(&id)) { + return Err(Error::msg(format!("Failed to delete (fs): {}", e))); + } + Ok(()) + } else { + Err(Error::msg("File not found")) + } +} + +#[rocket::get("/")] +pub async fn root() -> Result { + if let Ok(f) = NamedFile::open("./ui/index.html").await { + Ok(f) + } else { + Err(Status::InternalServerError) + } +} + +#[rocket::get("/")] +pub async fn get_blob( + sha256: &str, + fs: &State, + db: &State, +) -> Result { + let sha256 = if sha256.contains(".") { + sha256.split('.').next().unwrap() + } else { + sha256 + }; + let id = if let Ok(i) = hex::decode(sha256) { + i + } else { + return Err(Status::NotFound); + }; + + if id.len() != 32 { + return Err(Status::NotFound); + } + if let Ok(Some(info)) = db.get_file(&id).await { + if let Ok(f) = File::open(fs.get(&id)) { + return Ok(FilePayload { file: f, info }); + } + } + return Err(Status::NotFound); +} + +#[rocket::head("/")] +pub async fn head_blob(sha256: &str, fs: &State) -> Status { + let sha256 = if sha256.contains(".") { + sha256.split('.').next().unwrap() + } else { + sha256 + }; + let id = if let Ok(i) = hex::decode(sha256) { + i + } else { + return Status::NotFound; + }; + + if id.len() != 32 { + return Status::NotFound; + } + if fs.get(&id).exists() { + Status::Ok + } else { + Status::NotFound + } +} diff --git a/src/routes/nip96.rs b/src/routes/nip96.rs new file mode 100644 index 0000000..4b120ff --- /dev/null +++ b/src/routes/nip96.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; + +use chrono::Utc; +use rocket::form::Form; +use rocket::fs::TempFile; +use rocket::serde::json::Json; +use rocket::serde::Serialize; +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; +use crate::settings::Settings; + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct Nip96InfoDoc { + /// File upload and deletion are served from this url + pub api_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub download_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delegated_to_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_nips: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_types: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub plans: Option>, +} + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct Nip96Plan { + pub name: String, + pub is_nip98_required: bool, + /// landing page for this plan + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + pub max_byte_size: usize, + /// Range in days / 0 for no expiration + /// [7, 0] means it may vary from 7 days to unlimited persistence, + /// [0, 0] means it has no expiration + /// early expiration may be due to low traffic or any other factor + #[serde(skip_serializing_if = "Option::is_none")] + pub file_expiration: Option<(usize, usize)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub media_transformations: Option, +} + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct Nip96MediaTransformations { + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub video: Option>, +} + +#[derive(Responder)] +enum Nip96Response { + #[response(status = 500)] + GenericError(Json), + + #[response(status = 200)] + UploadResult(Json), +} + +impl Nip96Response { + fn error(msg: &str) -> Self { + Nip96Response::GenericError(Json(Nip96UploadResult { + status: "error".to_string(), + message: Some(msg.to_string()), + ..Default::default() + })) + } + + fn success(msg: &str) -> Self { + Nip96Response::UploadResult(Json(Nip96UploadResult { + status: "success".to_string(), + message: Some(msg.to_string()), + ..Default::default() + })) + } +} + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct Nip96UploadResult { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub processing_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nip94_event: Option, +} + +#[derive(Serialize, Default)] +#[serde(crate = "rocket::serde")] +struct Nip94Event { + pub tags: Vec>, +} + +#[derive(FromForm)] +struct Nip96Form<'r> { + file: TempFile<'r>, + expiration: Option, + size: usize, + alt: Option<&'r str>, + caption: Option<&'r str>, + media_type: Option<&'r str>, + content_type: Option<&'r str>, +} + +pub fn nip96_routes() -> Vec { + routes![get_info_doc, upload, delete] +} + +#[rocket::get("/.well-known/nostr/nip96.json")] +async fn get_info_doc(settings: &State) -> Json { + let mut plans = HashMap::new(); + plans.insert( + "free".to_string(), + Nip96Plan { + is_nip98_required: true, + max_byte_size: settings.max_upload_bytes, + ..Default::default() + }, + ); + Json(Nip96InfoDoc { + api_url: "/n96".to_string(), + download_url: Some("/".to_string()), + content_types: Some(vec![ + "image/*".to_string(), + "video/*".to_string(), + "audio/*".to_string(), + ]), + plans: Some(plans), + ..Default::default() + }) +} + +#[rocket::post("/n96", data = "
")] +async fn upload( + auth: Nip98Auth, + fs: &State, + db: &State, + settings: &State, + form: Form>, +) -> Nip96Response { + let file = match form.file.open().await { + Ok(f) => f, + Err(e) => return Nip96Response::error(&format!("Could not open file: {}", e)), + }; + match fs.put(file).await { + Ok(blob) => { + let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); + let user_id = match db.upsert_user(&pubkey_vec).await { + Ok(u) => u, + Err(e) => return Nip96Response::error(&format!("Could not save user: {}", e)), + }; + let file_upload = FileUpload { + id: blob.sha256, + user_id, + name: match &form.caption { + Some(c) => c.to_string(), + None => "".to_string(), + }, + size: blob.size, + mime_type: match &form.media_type { + Some(c) => c.to_string(), + None => "application/octet-stream".to_string(), + }, + created: Utc::now(), + }; + if let Err(e) = db.add_file(&file_upload).await { + return Nip96Response::error(&format!("Could not save file (db): {}", e)); + } + + let hex_id = hex::encode(&file_upload.id); + Nip96Response::UploadResult(Json(Nip96UploadResult { + status: "success".to_string(), + nip94_event: Some(Nip94Event { + tags: vec![ + vec![ + "url".to_string(), + format!("{}/{}", &settings.public_url, &hex_id), + ], + vec!["x".to_string(), hex_id], + vec!["m".to_string(), file_upload.mime_type], + ], + }), + ..Default::default() + })) + } + Err(e) => return Nip96Response::error(&format!("Could not save file: {}", e)), + } +} + +#[rocket::delete("/n96/")] +async fn delete( + sha256: &str, + auth: Nip98Auth, + fs: &State, + db: &State, +) -> Nip96Response { + match delete_file(sha256, &auth.event, fs, db).await { + Ok(()) => Nip96Response::success("File deleted."), + Err(e) => Nip96Response::error(&format!("Failed to delete file: {}", e)), + } +} diff --git a/src/settings.rs b/src/settings.rs index 48566e2..0730ba9 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -10,4 +10,10 @@ pub struct Settings { /// Database connection string mysql://localhost pub database: String, + + /// Maximum support filesize for uploading + pub max_upload_bytes: usize, + + /// Public facing url + pub public_url: String, }