Init
This commit is contained in:
commit
00351407d5
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
3388
Cargo.lock
generated
Normal file
3388
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "void_cat"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
log = "0.4.21"
|
||||||
|
nostr = "0.30.0"
|
||||||
|
pretty_env_logger = "0.5.0"
|
||||||
|
rocket = { version = "0.5.0", features = ["json"] }
|
||||||
|
tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
base64 = "0.21.7"
|
||||||
|
hex = "0.4.3"
|
||||||
|
serde = { version = "1.0.198", features = ["derive"] }
|
||||||
|
uuid = { version = "1.8.0", features = ["v4"] }
|
||||||
|
anyhow = "1.0.82"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
sqlx = { version = "0.7.4", features = ["mysql", "runtime-tokio"] }
|
||||||
|
config = { version = "0.14.0", features = ["toml"] }
|
8
config.toml
Normal file
8
config.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Listen address for webserver
|
||||||
|
listen = "127.0.0.1:8000"
|
||||||
|
|
||||||
|
# Database connection string (MYSQL)
|
||||||
|
database = "mysql://root:root@localhost:3366/void_cat"
|
||||||
|
|
||||||
|
# Directory to store uploads
|
||||||
|
storage_dir = "./data"
|
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
volumes:
|
||||||
|
db:
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb
|
||||||
|
environment:
|
||||||
|
- "MARIADB_ROOT_PASSWORD=root"
|
||||||
|
- "MARIADB_DATABASE=void_cat"
|
||||||
|
ports:
|
||||||
|
- "3366:3306"
|
||||||
|
volumes:
|
||||||
|
- "db:/var/lib/mysql"
|
20
migrations/20240428220811_init.sql
Normal file
20
migrations/20240428220811_init.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
create table users
|
||||||
|
(
|
||||||
|
id integer unsigned not null auto_increment primary key,
|
||||||
|
pubkey binary(32) not null,
|
||||||
|
created timestamp default current_timestamp
|
||||||
|
);
|
||||||
|
create unique index ix_user_pubkey on users (pubkey);
|
||||||
|
create table uploads
|
||||||
|
(
|
||||||
|
id binary(32) not null primary key,
|
||||||
|
user_id integer unsigned not null,
|
||||||
|
name varchar(256) not null,
|
||||||
|
size integer unsigned not null,
|
||||||
|
created timestamp default current_timestamp,
|
||||||
|
|
||||||
|
constraint fk_uploads_user
|
||||||
|
foreign key (user_id) references users (id)
|
||||||
|
on delete cascade
|
||||||
|
on update restrict
|
||||||
|
);
|
66
src/auth.rs
Normal file
66
src/auth.rs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::request::{FromRequest, Outcome};
|
||||||
|
use rocket::{async_trait, Request};
|
||||||
|
|
||||||
|
use base64::prelude::*;
|
||||||
|
use nostr::{Event, JsonUtil, Kind, Tag, TagKind, Timestamp};
|
||||||
|
|
||||||
|
pub struct BlossomAuth {
|
||||||
|
pub pubkey: String,
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for BlossomAuth {
|
||||||
|
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::Custom(24242) {
|
||||||
|
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",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// check expiration tag
|
||||||
|
if let Some(expiration) = event.tags.iter().find_map(|t| match t {
|
||||||
|
Tag::Expiration(v) => Some(v),
|
||||||
|
_ => None,
|
||||||
|
}) {
|
||||||
|
if *expiration <= Timestamp::now() {
|
||||||
|
return Outcome::Error((Status::new(401), "Expiration invalid"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Outcome::Error((Status::new(401), "Missing expiration tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(_) = event.verify() {
|
||||||
|
return Outcome::Error((Status::new(401), "Event signature invalid"));
|
||||||
|
}
|
||||||
|
Outcome::Success(BlossomAuth {
|
||||||
|
pubkey: event.pubkey.to_string(),
|
||||||
|
event,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Outcome::Error((Status::new(403), "Auth scheme must be Nostr"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Outcome::Error((Status::new(403), "Auth header not found"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
12
src/blob.rs
Normal file
12
src/blob.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct BlobDescriptor {
|
||||||
|
pub url: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub created: u64,
|
||||||
|
}
|
25
src/cors.rs
Normal file
25
src/cors.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use rocket::fairing::{Fairing, Info, Kind};
|
||||||
|
use rocket::http::Header;
|
||||||
|
use rocket::{Request, Response};
|
||||||
|
|
||||||
|
pub struct CORS;
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl Fairing for CORS {
|
||||||
|
fn info(&self) -> Info {
|
||||||
|
Info {
|
||||||
|
name: "CORS headers",
|
||||||
|
kind: Kind::Response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_response<'r>(&self, _req: &'r Request<'_>, response: &mut Response<'r>) {
|
||||||
|
response.set_header(Header::new("Access-Control-Allow-Origin", "*"));
|
||||||
|
response.set_header(Header::new(
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"POST, GET, HEAD, DELETE, OPTIONS",
|
||||||
|
));
|
||||||
|
response.set_header(Header::new("Access-Control-Allow-Headers", "*"));
|
||||||
|
response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
|
||||||
|
}
|
||||||
|
}
|
54
src/db.rs
Normal file
54
src/db.rs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
use sqlx::migrate::MigrateError;
|
||||||
|
use sqlx::{Error, Row};
|
||||||
|
|
||||||
|
#[derive(Clone, sqlx::FromRow)]
|
||||||
|
pub struct FileUpload {
|
||||||
|
pub id: Vec<u8>,
|
||||||
|
pub user_id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub created: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
pool: sqlx::pool::Pool<sqlx::mysql::MySql>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub async fn new(conn: &str) -> Result<Self, Error> {
|
||||||
|
let db = sqlx::mysql::MySqlPool::connect(conn).await?;
|
||||||
|
Ok(Self { pool: db })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn migrate(&self) -> Result<(), MigrateError> {
|
||||||
|
sqlx::migrate!("./migrations/").run(&self.pool).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_user(&self, pubkey: Vec<u8>) -> Result<u32, Error> {
|
||||||
|
let res = sqlx::query("insert into users(pubkey) values(?) returning id")
|
||||||
|
.bind(pubkey)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
res.try_get(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_file(&self, file: FileUpload) -> Result<(), Error> {
|
||||||
|
sqlx::query("insert into uploads(id,user_id,name,size) values(?,?,?,?)")
|
||||||
|
.bind(&file.id)
|
||||||
|
.bind(&file.user_id)
|
||||||
|
.bind(&file.name)
|
||||||
|
.bind(&file.size)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ?")
|
||||||
|
.bind(&pubkey)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
91
src/filesystem.rs
Normal file
91
src/filesystem.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use std::env::temp_dir;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::SeekFrom;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use rocket::data::DataStream;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FileSystemResult {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub sha256: Vec<u8>,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileStore {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileStore {
|
||||||
|
pub fn new(settings: Settings) -> Self {
|
||||||
|
Self {
|
||||||
|
path: settings.storage_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a file path by id
|
||||||
|
pub fn get(&self, id: &Vec<u8>) -> PathBuf {
|
||||||
|
self.map_path(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a new file
|
||||||
|
pub async fn put(&self, stream: DataStream<'_>) -> Result<FileSystemResult, Error> {
|
||||||
|
let random_id = uuid::Uuid::new_v4();
|
||||||
|
let tmp_path = FileStore::map_temp(random_id);
|
||||||
|
|
||||||
|
let file = stream.into_file(&tmp_path).await;
|
||||||
|
match file {
|
||||||
|
Err(e) => Err(Error::from(e)),
|
||||||
|
Ok(file) => {
|
||||||
|
let size = file.n.written;
|
||||||
|
let mut file = file.value;
|
||||||
|
let hash = FileStore::hash_file(&mut file).await?;
|
||||||
|
let dst_path = self.map_path(&hash);
|
||||||
|
if let Err(e) = fs::rename(&tmp_path, &dst_path) {
|
||||||
|
fs::remove_file(&tmp_path)?;
|
||||||
|
Err(Error::from(e))
|
||||||
|
} else {
|
||||||
|
Ok(FileSystemResult {
|
||||||
|
size,
|
||||||
|
sha256: hash,
|
||||||
|
path: dst_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hash_file(file: &mut File) -> Result<Vec<u8>, Error> {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
file.seek(SeekFrom::Start(0)).await?;
|
||||||
|
let mut buf = [0; 4096];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf).await?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
let res = hasher.finalize();
|
||||||
|
Ok(res.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_temp(id: uuid::Uuid) -> PathBuf {
|
||||||
|
temp_dir().join(id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_path(&self, id: &Vec<u8>) -> PathBuf {
|
||||||
|
let id = hex::encode(id);
|
||||||
|
Path::new(&self.path)
|
||||||
|
.join(id[0..2].to_string())
|
||||||
|
.join(id[2..4].to_string())
|
||||||
|
.join(id)
|
||||||
|
}
|
||||||
|
}
|
50
src/main.rs
Normal file
50
src/main.rs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod blob;
|
||||||
|
mod db;
|
||||||
|
mod filesystem;
|
||||||
|
mod routes;
|
||||||
|
mod settings;
|
||||||
|
mod cors;
|
||||||
|
|
||||||
|
#[rocket::main]
|
||||||
|
async fn main() -> Result<(), Error> {
|
||||||
|
pretty_env_logger::init();
|
||||||
|
|
||||||
|
let builder = Config::builder()
|
||||||
|
.add_source(config::File::with_name("config.toml"))
|
||||||
|
.add_source(config::Environment::with_prefix("APP"))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let settings: Settings = builder.try_deserialize()?;
|
||||||
|
|
||||||
|
let db = Database::new(&settings.database).await?;
|
||||||
|
|
||||||
|
info!("Running DB migration");
|
||||||
|
db.migrate().await?;
|
||||||
|
|
||||||
|
let rocket = rocket::build()
|
||||||
|
.manage(FileStore::new(settings.clone()))
|
||||||
|
.manage(settings.clone())
|
||||||
|
.manage(db.clone())
|
||||||
|
.attach(CORS)
|
||||||
|
.mount("/", routes::all())
|
||||||
|
.launch()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = rocket {
|
||||||
|
error!("Rocker error {}", e);
|
||||||
|
Err(Error::from(e))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
114
src/routes.rs
Normal file
114
src/routes.rs
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
use crate::auth::BlossomAuth;
|
||||||
|
use crate::blob::BlobDescriptor;
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::filesystem::FileStore;
|
||||||
|
use nostr::prelude::hex;
|
||||||
|
use nostr::Tag;
|
||||||
|
use rocket::data::ToByteUnit;
|
||||||
|
use rocket::fs::NamedFile;
|
||||||
|
use rocket::http::Status;
|
||||||
|
use rocket::request::{FromRequest, Outcome};
|
||||||
|
use rocket::response::status::NotFound;
|
||||||
|
use rocket::serde::json::Json;
|
||||||
|
use rocket::{async_trait, routes, Data, Request, Route, State};
|
||||||
|
|
||||||
|
pub fn all() -> Vec<Route> {
|
||||||
|
routes![root, get_blob, get_blob_check, upload, list_files]
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}) {
|
||||||
|
if t == method {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/")]
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"Hello welcome to void_cat_rs"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/<sha256>")]
|
||||||
|
async fn get_blob(sha256: &str, fs: &State<FileStore>) -> Result<NamedFile, Status> {
|
||||||
|
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(f) = NamedFile::open(fs.get(&id)).await {
|
||||||
|
Ok(f)
|
||||||
|
} else {
|
||||||
|
Err(Status::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::head("/<sha256>")]
|
||||||
|
async fn get_blob_check(sha256: &str, fs: &State<FileStore>) -> Status {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::put("/upload", data = "<data>")]
|
||||||
|
async fn upload(auth: BlossomAuth, fs: &State<FileStore>, data: Data<'_>) -> Status {
|
||||||
|
if !check_method(&auth.event, "upload") {
|
||||||
|
return Status::NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs.put(data.open(8.gigabytes())).await {
|
||||||
|
Ok(blob) => Status::Ok,
|
||||||
|
Err(e) => Status::InternalServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::get("/list/<pubkey>")]
|
||||||
|
async fn list_files(
|
||||||
|
auth: BlossomAuth,
|
||||||
|
db: &State<Database>,
|
||||||
|
pubkey: String,
|
||||||
|
) -> Result<Json<Vec<BlobDescriptor>>, Status> {
|
||||||
|
if !check_method(&auth.event, "list") {
|
||||||
|
return Err(Status::NotFound);
|
||||||
|
}
|
||||||
|
let id = if let Ok(i) = hex::decode(pubkey) {
|
||||||
|
i
|
||||||
|
} else {
|
||||||
|
return Err(Status::NotFound);
|
||||||
|
};
|
||||||
|
if let Ok(files) = db.list_files(&id).await {
|
||||||
|
Ok(Json(
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.map(|f| BlobDescriptor {
|
||||||
|
url: "".to_string(),
|
||||||
|
sha256: hex::encode(&f.id),
|
||||||
|
size: f.size,
|
||||||
|
mime_type: None,
|
||||||
|
created: f.created,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(Status::InternalServerError)
|
||||||
|
}
|
||||||
|
}
|
13
src/settings.rs
Normal file
13
src/settings.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Listen addr:port
|
||||||
|
pub listen: Option<String>,
|
||||||
|
|
||||||
|
/// Directory to store files
|
||||||
|
pub storage_dir: String,
|
||||||
|
|
||||||
|
/// Database connection string mysql://localhost
|
||||||
|
pub database: String,
|
||||||
|
}
|
10
ui/index.html
Normal file
10
ui/index.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Test Page</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user