This commit is contained in:
kieran 2024-05-01 15:00:43 +01:00
parent c44ee0ee85
commit c4aae42fcd
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
14 changed files with 685 additions and 298 deletions

View File

@ -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"
storage_dir = "./data"
# Maximum support filesize for uploading
max_upload_bytes = 104857600
# Public facing url
public_url = "http://localhost:8000"

View File

@ -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<String>,
@ -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 {

2
src/auth/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod blossom;
pub mod nip98;

106
src/auth/nip98.rs Normal file
View 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"))
};
}
}

View File

@ -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,
}
}
}
}

View File

@ -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;

View File

@ -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<u8>) -> Result<Vec<FileUpload>, Error> {
let results: Vec<FileUpload> = 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<FileUpload> = sqlx::query_as(
"select * from uploads where user_id = (select id from users where pubkey = ?)",
)
.bind(&pubkey)
.fetch_all(&self.pool)
.await?;
Ok(results)
}
}

View File

@ -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<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 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,
})

View File

@ -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(())
}
}
}

View File

@ -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
View 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
View 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
View 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)),
}
}

View File

@ -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,
}