Nip96
This commit is contained in:
parent
c44ee0ee85
commit
c4aae42fcd
@ -5,4 +5,10 @@ listen = "127.0.0.1:8000"
|
|||||||
database = "mysql://root:root@localhost:3366/void_cat"
|
database = "mysql://root:root@localhost:3366/void_cat"
|
||||||
|
|
||||||
# Directory to store uploads
|
# Directory to store uploads
|
||||||
storage_dir = "./data"
|
storage_dir = "./data"
|
||||||
|
|
||||||
|
# Maximum support filesize for uploading
|
||||||
|
max_upload_bytes = 104857600
|
||||||
|
|
||||||
|
# Public facing url
|
||||||
|
public_url = "http://localhost:8000"
|
@ -1,9 +1,9 @@
|
|||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
use log::info;
|
use log::info;
|
||||||
use nostr::{Event, JsonUtil, Kind, Tag, Timestamp};
|
use nostr::{Event, JsonUtil, Kind, Tag, Timestamp};
|
||||||
use rocket::{async_trait, Request};
|
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::request::{FromRequest, Outcome};
|
use rocket::request::{FromRequest, Outcome};
|
||||||
|
use rocket::{async_trait, Request};
|
||||||
|
|
||||||
pub struct BlossomAuth {
|
pub struct BlossomAuth {
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
@ -56,10 +56,12 @@ impl<'r> FromRequest<'r> for BlossomAuth {
|
|||||||
info!("{}", event.as_json());
|
info!("{}", event.as_json());
|
||||||
Outcome::Success(BlossomAuth {
|
Outcome::Success(BlossomAuth {
|
||||||
event,
|
event,
|
||||||
content_type: request.headers().iter().find_map(|h| if h.name == "content-type" {
|
content_type: request.headers().iter().find_map(|h| {
|
||||||
Some(h.value.to_string())
|
if h.name == "content-type" {
|
||||||
} else {
|
Some(h.value.to_string())
|
||||||
None
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
2
src/auth/mod.rs
Normal file
2
src/auth/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod blossom;
|
||||||
|
pub mod nip98;
|
106
src/auth/nip98.rs
Normal file
106
src/auth/nip98.rs
Normal file
@ -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<String>,
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for Nip98Auth {
|
||||||
|
type Error = &'static str;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
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::<Absolute>(&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"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
11
src/blob.rs
11
src/blob.rs
@ -13,14 +13,15 @@ pub struct BlobDescriptor {
|
|||||||
pub created: u64,
|
pub created: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&FileUpload> for BlobDescriptor {
|
impl BlobDescriptor {
|
||||||
fn from(value: &FileUpload) -> Self {
|
pub fn from_upload(value: &FileUpload, public_url: &String) -> Self {
|
||||||
|
let id_hex = hex::encode(&value.id);
|
||||||
Self {
|
Self {
|
||||||
url: "".to_string(),
|
url: format!("{}/{}", public_url, &id_hex),
|
||||||
sha256: hex::encode(&value.id),
|
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,
|
created: value.created.timestamp() as u64,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use std::io::Cursor;
|
|
||||||
use rocket::fairing::{Fairing, Info, Kind};
|
use rocket::fairing::{Fairing, Info, Kind};
|
||||||
use rocket::http::{Header, Method, Status};
|
use rocket::http::{Header, Method, Status};
|
||||||
use rocket::{Data, Request, Response};
|
use rocket::{Request, Response};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
pub struct CORS;
|
pub struct CORS;
|
||||||
|
|
||||||
|
14
src/db.rs
14
src/db.rs
@ -1,6 +1,6 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::{Error, FromRow, Row};
|
|
||||||
use sqlx::migrate::MigrateError;
|
use sqlx::migrate::MigrateError;
|
||||||
|
use sqlx::{Error, FromRow, Row};
|
||||||
|
|
||||||
#[derive(Clone, FromRow)]
|
#[derive(Clone, FromRow)]
|
||||||
pub struct FileUpload {
|
pub struct FileUpload {
|
||||||
@ -38,7 +38,7 @@ impl Database {
|
|||||||
.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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +78,12 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_files(&self, pubkey: &Vec<u8>) -> Result<Vec<FileUpload>, Error> {
|
pub async fn list_files(&self, pubkey: &Vec<u8>) -> Result<Vec<FileUpload>, Error> {
|
||||||
let results: Vec<FileUpload> = sqlx::query_as("select * from uploads where user_id = (select id from users where pubkey = ?)")
|
let results: Vec<FileUpload> = sqlx::query_as(
|
||||||
.bind(&pubkey)
|
"select * from uploads where user_id = (select id from users where pubkey = ?)",
|
||||||
.fetch_all(&self.pool)
|
)
|
||||||
.await?;
|
.bind(&pubkey)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
use std::env::temp_dir;
|
use std::env::temp_dir;
|
||||||
use std::fs;
|
use std::io::{SeekFrom};
|
||||||
use std::io::SeekFrom;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::{fs};
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use log::info;
|
use log::info;
|
||||||
use rocket::data::DataStream;
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, BufWriter};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
use crate::db::Database;
|
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -37,14 +35,20 @@ impl FileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Store a new file
|
/// Store a new file
|
||||||
pub async fn put(&self, stream: DataStream<'_>) -> Result<FileSystemResult, Error> {
|
pub async fn put<TStream>(&self, mut stream: TStream) -> Result<FileSystemResult, Error>
|
||||||
|
where
|
||||||
|
TStream: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
let random_id = uuid::Uuid::new_v4();
|
let random_id = uuid::Uuid::new_v4();
|
||||||
let tmp_path = FileStore::map_temp(random_id);
|
let tmp_path = FileStore::map_temp(random_id);
|
||||||
|
|
||||||
let mut file = File::options()
|
let mut file = File::options()
|
||||||
.create(true).write(true).read(true)
|
.create(true)
|
||||||
.open(tmp_path.clone()).await?;
|
.write(true)
|
||||||
let n = stream.stream_to(&mut BufWriter::new(&mut file)).await?;
|
.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());
|
info!("File saved to temp path: {}", tmp_path.to_str().unwrap());
|
||||||
let hash = FileStore::hash_file(&mut file).await?;
|
let hash = FileStore::hash_file(&mut file).await?;
|
||||||
@ -55,7 +59,7 @@ impl FileStore {
|
|||||||
Err(Error::from(e))
|
Err(Error::from(e))
|
||||||
} else {
|
} else {
|
||||||
Ok(FileSystemResult {
|
Ok(FileSystemResult {
|
||||||
size: n.written,
|
size: n,
|
||||||
sha256: hash,
|
sha256: hash,
|
||||||
path: dst_path,
|
path: dst_path,
|
||||||
})
|
})
|
||||||
|
17
src/main.rs
17
src/main.rs
@ -1,20 +1,21 @@
|
|||||||
use crate::db::Database;
|
|
||||||
use crate::filesystem::FileStore;
|
|
||||||
use crate::settings::Settings;
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
use rocket::fairing::{Fairing, Info};
|
|
||||||
use rocket::routes;
|
use rocket::routes;
|
||||||
|
|
||||||
use crate::cors::CORS;
|
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 auth;
|
||||||
mod blob;
|
mod blob;
|
||||||
|
mod cors;
|
||||||
mod db;
|
mod db;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod cors;
|
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
async fn main() -> Result<(), Error> {
|
async fn main() -> Result<(), Error> {
|
||||||
@ -37,7 +38,9 @@ async fn main() -> Result<(), Error> {
|
|||||||
.manage(settings.clone())
|
.manage(settings.clone())
|
||||||
.manage(db.clone())
|
.manage(db.clone())
|
||||||
.attach(CORS)
|
.attach(CORS)
|
||||||
.mount("/", routes::all())
|
.mount("/", routes::blossom_routes())
|
||||||
|
.mount("/", routes::nip96_routes())
|
||||||
|
.mount("/", routes![root, get_blob, head_blob])
|
||||||
.launch()
|
.launch()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@ -47,4 +50,4 @@ async fn main() -> Result<(), Error> {
|
|||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
262
src/routes.rs
262
src/routes.rs
@ -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<Route> {
|
|
||||||
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<BlossomError>),
|
|
||||||
|
|
||||||
#[response(status = 500)]
|
|
||||||
GenericError(Json<BlossomError>),
|
|
||||||
|
|
||||||
#[response(status = 200)]
|
|
||||||
File(BlossomFile),
|
|
||||||
|
|
||||||
#[response(status = 200)]
|
|
||||||
BlobDescriptor(Json<BlobDescriptor>),
|
|
||||||
|
|
||||||
#[response(status = 200)]
|
|
||||||
BlobDescriptorList(Json<Vec<BlobDescriptor>>),
|
|
||||||
|
|
||||||
StatusOnly(Status),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BlossomResponse {
|
|
||||||
pub fn error(msg: impl Into<String>) -> 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<NamedFile, Status> {
|
|
||||||
if let Ok(f) = NamedFile::open("./ui/index.html").await {
|
|
||||||
Ok(f)
|
|
||||||
} else {
|
|
||||||
Err(Status::InternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::get("/<sha256>")]
|
|
||||||
async fn get_blob(sha256: &str, fs: &State<FileStore>, db: &State<Database>) -> 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("/<sha256>")]
|
|
||||||
async fn head_blob(sha256: &str, fs: &State<FileStore>) -> 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("/<sha256>")]
|
|
||||||
async fn delete_blob(sha256: &str, auth: BlossomAuth, fs: &State<FileStore>, db: &State<Database>) -> 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 = "<data>")]
|
|
||||||
async fn upload(auth: BlossomAuth, fs: &State<FileStore>, db: &State<Database>, 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::<usize>().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/<pubkey>")]
|
|
||||||
async fn list_files(
|
|
||||||
db: &State<Database>,
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
164
src/routes/blossom.rs
Normal file
164
src/routes/blossom.rs
Normal file
@ -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<Route> {
|
||||||
|
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<BlossomError>),
|
||||||
|
|
||||||
|
#[response(status = 200)]
|
||||||
|
BlobDescriptor(Json<BlobDescriptor>),
|
||||||
|
|
||||||
|
#[response(status = 200)]
|
||||||
|
BlobDescriptorList(Json<Vec<BlobDescriptor>>),
|
||||||
|
|
||||||
|
StatusOnly(Status),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlossomResponse {
|
||||||
|
pub fn error(msg: impl Into<String>) -> 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("/<sha256>")]
|
||||||
|
async fn delete_blob(
|
||||||
|
sha256: &str,
|
||||||
|
auth: BlossomAuth,
|
||||||
|
fs: &State<FileStore>,
|
||||||
|
db: &State<Database>,
|
||||||
|
) -> 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 = "<data>")]
|
||||||
|
async fn upload(
|
||||||
|
auth: BlossomAuth,
|
||||||
|
fs: &State<FileStore>,
|
||||||
|
db: &State<Database>,
|
||||||
|
settings: &State<Settings>,
|
||||||
|
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::<usize>().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/<pubkey>")]
|
||||||
|
async fn list_files(
|
||||||
|
db: &State<Database>,
|
||||||
|
settings: &State<Settings>,
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
138
src/routes/mod.rs
Normal file
138
src/routes/mod.rs
Normal file
@ -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<NamedFile, Status> {
|
||||||
|
if let Ok(f) = NamedFile::open("./ui/index.html").await {
|
||||||
|
Ok(f)
|
||||||
|
} else {
|
||||||
|
Err(Status::InternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/<sha256>")]
|
||||||
|
pub async fn get_blob(
|
||||||
|
sha256: &str,
|
||||||
|
fs: &State<FileStore>,
|
||||||
|
db: &State<Database>,
|
||||||
|
) -> Result<FilePayload, Status> {
|
||||||
|
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("/<sha256>")]
|
||||||
|
pub async fn head_blob(sha256: &str, fs: &State<FileStore>) -> 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
|
||||||
|
}
|
||||||
|
}
|
215
src/routes/nip96.rs
Normal file
215
src/routes/nip96.rs
Normal file
@ -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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub delegated_to_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supported_nips: Option<Vec<usize>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tos_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content_types: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plans: Option<HashMap<String, Nip96Plan>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
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<Nip96MediaTransformations>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
struct Nip96MediaTransformations {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub video: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Responder)]
|
||||||
|
enum Nip96Response {
|
||||||
|
#[response(status = 500)]
|
||||||
|
GenericError(Json<Nip96UploadResult>),
|
||||||
|
|
||||||
|
#[response(status = 200)]
|
||||||
|
UploadResult(Json<Nip96UploadResult>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub processing_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub nip94_event: Option<Nip94Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Default)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
struct Nip94Event {
|
||||||
|
pub tags: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromForm)]
|
||||||
|
struct Nip96Form<'r> {
|
||||||
|
file: TempFile<'r>,
|
||||||
|
expiration: Option<usize>,
|
||||||
|
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<Route> {
|
||||||
|
routes![get_info_doc, upload, delete]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/.well-known/nostr/nip96.json")]
|
||||||
|
async fn get_info_doc(settings: &State<Settings>) -> Json<Nip96InfoDoc> {
|
||||||
|
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 = "<form>")]
|
||||||
|
async fn upload(
|
||||||
|
auth: Nip98Auth,
|
||||||
|
fs: &State<FileStore>,
|
||||||
|
db: &State<Database>,
|
||||||
|
settings: &State<Settings>,
|
||||||
|
form: Form<Nip96Form<'_>>,
|
||||||
|
) -> 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/<sha256>")]
|
||||||
|
async fn delete(
|
||||||
|
sha256: &str,
|
||||||
|
auth: Nip98Auth,
|
||||||
|
fs: &State<FileStore>,
|
||||||
|
db: &State<Database>,
|
||||||
|
) -> 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)),
|
||||||
|
}
|
||||||
|
}
|
@ -10,4 +10,10 @@ pub struct Settings {
|
|||||||
|
|
||||||
/// Database connection string mysql://localhost
|
/// Database connection string mysql://localhost
|
||||||
pub database: String,
|
pub database: String,
|
||||||
|
|
||||||
|
/// Maximum support filesize for uploading
|
||||||
|
pub max_upload_bytes: usize,
|
||||||
|
|
||||||
|
/// Public facing url
|
||||||
|
pub public_url: String,
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user