feat: initial commit

This commit is contained in:
nullishamy 2025-04-10 19:40:50 +01:00
commit a4a1ef0745
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
19 changed files with 4436 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.direnv/
target

3469
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

10
Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[workspace]
resolver = "2"
members = [ "ferri-cli","ferri-main", "ferri-server"]
[workspace.dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = "1.0.219"
rocket = { version = "0.5.1", features = ["json"] }
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite", "macros" ], default-features = false }
uuid = { version = "1.16.0", features = ["v4"] }

26
Rocket.toml Normal file
View file

@ -0,0 +1,26 @@
[default]
address = "0.0.0.0"
port = 8080
workers = 16
max_blocking = 512
keep_alive = 5
ident = "Rocket"
ip_header = "X-Real-IP" # set to `false` to disable
log_level = "normal"
temp_dir = "/tmp"
cli_colors = true
[default.limits]
form = "64 kB"
json = "1 MiB"
msgpack = "2 MiB"
"file/jpg" = "5 MiB"
[default.shutdown]
ctrlc = true
signals = ["term", "hup"]
grace = 5
mercy = 5
[default.databases.sqlite_ferri]
url = "./ferri.db"

11
ferri-cli/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
main = { path = "../ferri-main/" }
server = { path = "../ferri-server" }
rocket = { workspace = true }
sqlx = { workspace = true }
clap = { version = "4", features = ["derive"] }

41
ferri-cli/src/main.rs Normal file
View file

@ -0,0 +1,41 @@
use server::launch;
extern crate rocket;
use sqlx::sqlite::SqlitePool;
use std::env;
use clap::Parser;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long)]
init: bool
}
#[rocket::main]
async fn main() {
let cli = Cli::parse();
if cli.init {
// Seed DB
let pool = SqlitePool::connect(&env::var("DATABASE_URL").unwrap()).await.unwrap();
let mut conn = pool.acquire().await.unwrap();
sqlx::query!(r#"
INSERT INTO actor (id, inbox, outbox)
VALUES (?1, ?2, ?3)
"#, "https://ferri.amy.mov/users/amy", "https://ferri.amy.mov/users/amy/inbox", "https://ferri.amy.mov/users/amy/outbox")
.execute(&mut *conn)
.await.unwrap();
sqlx::query!(r#"
INSERT INTO user (id, actor_id, display_name)
VALUES (?1, ?2, ?3)
"#, "amy", "https://ferri.amy.mov/users/amy", "amy")
.execute(&mut *conn)
.await.unwrap();
} else {
let _ = launch().launch().await;
}
}

7
ferri-main/Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "main"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }

27
ferri-main/src/ap/mod.rs Normal file
View file

@ -0,0 +1,27 @@
pub type ObjectId = String;
pub enum ObjectType {
Person,
}
pub struct Object {
id: ObjectId,
ty: ObjectType
}
pub struct Actor {
obj: Object,
inbox: Inbox,
outbox: Outbox,
}
pub struct Inbox {}
pub struct Outbox {}
pub struct Message {}
pub struct Activity {
}

1
ferri-main/src/lib.rs Normal file
View file

@ -0,0 +1 @@
pub mod ap;

17
ferri-server/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
main = { path = "../ferri-main/" }
rocket = { workspace = true }
rocket_db_pools = { version = "0.2.0", features = ["sqlx_sqlite"] }
reqwest = { workspace = true }
sqlx = { workspace = true }
base64 = "0.22.1"
rsa = { version = "0.9.8", features = ["sha2"] }
rand = "0.8"
url = "2.5.4"
chrono = "0.4.40"
uuid = { workspace = true }

163
ferri-server/src/ap.rs Normal file
View file

@ -0,0 +1,163 @@
use rocket::serde::{Serialize, Deserialize};
#[derive(Deserialize, Serialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct Link {
pub rel: String,
#[serde(rename = "type")]
pub ty: Option<String>,
pub href: Option<String>
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct WebfingerResponse {
pub subject: String,
pub aliases: Vec<String>,
pub links: Vec<Link>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(crate = "rocket::serde")]
pub struct UserKey {
pub id: String,
pub owner: String,
#[serde(rename = "publicKeyPem")]
pub public_key: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(crate = "rocket::serde")]
pub struct Person {
// FIXME: This is because Masto sends an array but we don't care
#[serde(rename = "@context")]
#[serde(skip_deserializing)]
pub context: String,
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub following: String,
pub followers: String,
pub inbox: String,
pub outbox: String,
pub preferred_username: String,
pub name: String,
pub summary: String,
pub public_key: Option<UserKey>
// pub url: String,
// pub manually_approves_followers: bool,
// pub discoverable: bool,
// pub indexable: bool,
// pub published: String,
// pub memorial: bool,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct MinimalActivity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct DeleteActivity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub object: String,
pub actor: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct CreateActivity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub object: Post,
pub actor: String,
pub to: Vec<String>,
pub cc: Vec<String>,
#[serde(rename = "published")]
pub ts: String,
pub summary: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct FollowActivity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub object: String,
pub actor: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct AcceptActivity {
#[serde(rename = "type")]
pub ty: String,
pub object: String,
pub actor: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct Post {
// FIXME: This is because Masto sends an array but we don't care
#[serde(rename = "@context")]
#[serde(skip_deserializing)]
pub context: String,
pub id: String,
#[serde(rename = "type")]
pub ty: String,
#[serde(rename = "published")]
pub ts: String,
pub content: String,
pub to: Vec<String>,
pub cc: Vec<String>
}
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Activity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub summary: String,
pub actor: String,
pub object: String,
pub published: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(crate = "rocket::serde")]
pub struct Object {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
pub object: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(crate = "rocket::serde")]
pub struct OrderedCollection {
pub summary: String,
#[serde(rename = "type")]
pub ty: String,
pub total_items: u64,
pub ordered_items: Vec<String>
}

491
ferri-server/src/lib.rs Normal file
View file

@ -0,0 +1,491 @@
use rocket::serde::json::Json;
use rocket::serde::json::serde_json;
use rocket::{Rocket, Build, build, get, post, routes, http::{MediaType, ContentType}};
use reqwest;
use uuid::Uuid;
use base64::prelude::*;
use rocket::serde::Serialize;
use rocket::serde::Deserialize;
use rocket::Request;
use rocket::request::Outcome;
use rocket::request::FromRequest;
use rocket::FromForm;
use rsa::{RsaPrivateKey, pkcs8::DecodePrivateKey};
use rsa::pkcs1v15::SigningKey;
use rsa::signature::{RandomizedSigner, SignatureEncoding};
use rsa::sha2::{Digest, Sha256};
use rocket::form::Form;
use url::Url;
use chrono::Utc;
mod ap;
use rocket_db_pools::{Database, Connection};
use rocket_db_pools::sqlx::{self, Row};
#[derive(Database)]
#[database("sqlite_ferri")]
struct Db(sqlx::SqlitePool);
#[get("/users/<user>/inbox")]
async fn inbox(user: String) -> Json<ap::OrderedCollection> {
dbg!(&user);
Json(ap::OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Inbox for {}", user),
total_items: 0,
ordered_items: vec![]
})
}
#[post("/users/<user>/inbox", data="<body>")]
async fn post_inbox(mut db: Connection<Db>, user: String, body: String) {
let client = reqwest::Client::new();
let min = serde_json::from_str::<ap::MinimalActivity>(&body).unwrap();
match min.ty.as_str() {
"Delete" => {
let activity = serde_json::from_str::<ap::DeleteActivity>(&body);
dbg!(activity);
}
"Follow" => {
let activity = serde_json::from_str::<ap::FollowActivity>(&body).unwrap();
dbg!(&activity);
let user = client.get(&activity.actor)
.header("Accept", "application/activity+json")
.send()
.await.unwrap()
.json::<ap::Person>()
.await.unwrap();
sqlx::query!(r#"
INSERT INTO actor (id, inbox, outbox)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#, activity.actor, user.inbox, user.outbox)
.execute(&mut **db)
.await.unwrap();
sqlx::query!(r#"
INSERT INTO follow (id, follower_id, followed_id)
VALUES ( ?1, ?2, ?3 )
ON CONFLICT(id) DO NOTHING;
"#, activity.id, activity.actor, activity.object)
.execute(&mut **db)
.await.unwrap();
let accept = ap::AcceptActivity {
ty: "Accept".to_string(),
actor: "https://ferri.amy.mov/users/amy".to_string(),
object: activity.id
};
let key_id = "https://ferri.amy.mov/users/amy#main-key".to_string();
let document = serde_json::to_string(&accept).unwrap();
let signature = sign_post_request(key_id, user.inbox.clone(), document);
dbg!(&signature);
let follow_res = client.post(user.inbox)
.header("Content-Type", "application/activity+json")
.header("Date", signature.date)
.header("Digest", signature.digest)
.header("Signature", signature.signature)
.json(&accept)
.send()
.await.unwrap()
.text()
.await.unwrap();
dbg!(follow_res);
}
unknown => {
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
}
}
dbg!(min);
println!("body: {}", body);
}
#[get("/users/<user>/outbox")]
async fn outbox(user: String) -> Json<ap::OrderedCollection> {
dbg!(&user);
Json(ap::OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Outbox for {}", user),
total_items: 0,
ordered_items: vec![]
})
}
#[get("/users/<user>/followers")]
async fn followers(mut db: Connection<Db>, user: String) -> Json<ap::OrderedCollection> {
let target = FerriUser::by_name(&user, &mut **db).await;
let followers = sqlx::query!( r#"
SELECT follower_id FROM follow
WHERE followed_id = ?
"#, target.actor_id)
.fetch_all(&mut **db)
.await.unwrap();
Json(ap::OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Followers for {}", user),
total_items: 1,
ordered_items: followers.into_iter().map(|f| f.follower_id).collect::<Vec<_>>()
})
}
#[derive(Debug)]
struct FerriUser {
id: String,
actor_id: String,
display_name: String
}
impl FerriUser {
async fn by_name<'a>(
name: &'a str,
conn: impl sqlx::Executor<'a, Database = sqlx::Sqlite>
) -> FerriUser {
sqlx::query_as!(FerriUser, r#"
SELECT * FROM user
WHERE display_name = ?
"#, name)
.fetch_one(conn)
.await.unwrap()
}
}
#[get("/users/<user>/following")]
async fn following(mut db: Connection<Db>, user: String) -> Json<ap::OrderedCollection> {
let target = FerriUser::by_name(&user, &mut **db).await;
let following = sqlx::query!( r#"
SELECT followed_id FROM follow
WHERE follower_id = ?
"#, target.actor_id)
.fetch_all(&mut **db)
.await.unwrap();
Json(ap::OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Following for {}", user),
total_items: 1,
ordered_items: following.into_iter().map(|f| f.followed_id).collect::<Vec<_>>()
})
}
fn activity_type() -> ContentType {
ContentType(MediaType::new("application", "activity+json"))
}
#[get("/users/<user>/posts/<post>")]
async fn get_post(user: String, post: String) -> (ContentType, Json<ap::Post>) {
(activity_type(), Json(ap::Post {
id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post),
context: "https://www.w3.org/ns/activitystreams".to_string(),
ty: "Note".to_string(),
content: "My first post".to_string(),
ts: "2025-04-10T10:48:11Z".to_string(),
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
}))
}
#[get("/users/<user>")]
async fn user(user: String) -> (ContentType, Json<ap::Person>) {
(activity_type(), Json(ap::Person {
context: "https://www.w3.org/ns/activitystreams".to_string(),
ty: "Person".to_string(),
id: format!("https://ferri.amy.mov/users/{}", user),
name: user.clone(),
preferred_username: user.clone(),
followers: format!("https://ferri.amy.mov/users/{}/followers", user),
following: format!("https://ferri.amy.mov/users/{}/following", user),
summary: format!("ferri {}", user),
inbox: format!("https://ferri.amy.mov/users/{}/inbox", user),
outbox: format!("https://ferri.amy.mov/users/{}/outbox", user),
public_key: Some(ap::UserKey {
id: format!("https://ferri.amy.mov/users/{}#main-key", user),
owner: format!("https://ferri.amy.mov/users/{}", user),
public_key: include_str!("../../public.pem").to_string(),
})
}))
}
#[get("/")]
async fn user_profile() -> (ContentType, &'static str) {
(ContentType::HTML, "<p>hello</p>")
}
#[get("/activities/<activity>")]
async fn activity(activity: String) {
dbg!(activity);
}
// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social
#[get("/.well-known/webfinger?<resource>")]
async fn webfinger(mut db: Connection<Db>, resource: &str) -> Json<ap::WebfingerResponse> {
println!("Webfinger request for {}", resource);
let acct = resource.strip_prefix("acct:").unwrap();
let (user, _) = acct.split_once("@").unwrap();
let user = FerriUser::by_name(user, &mut **db).await;
dbg!(&user);
Json(ap::WebfingerResponse {
subject: resource.to_string(),
aliases: vec![
format!("https://ferri.amy.mov/users/{}", user.id),
format!("https://ferri.amy.mov/@{}", user.id)
],
links: vec![
ap::Link {
rel: "http://webfinger.net/rel/profile-page".to_string(),
ty: Some("text/html".to_string()),
href: Some(format!("https://ferri.amy.mov/@{}", user.id))
},
ap::Link {
rel: "self".to_string(),
ty: Some("application/activity+json".to_string()),
href: Some(format!("https://ferri.amy.mov/users/{}", user.id))
}
]
})
}
async fn resolve_user(acct: &str, host: &str) -> ap::Person {
let client = reqwest::Client::new();
let url = format!("https://{}/.well-known/webfinger?resource=acct:{}", host, acct);
let wf = client.get(url)
.send()
.await.unwrap()
.json::<ap::WebfingerResponse>()
.await.unwrap();
let actor_link = wf.links
.iter()
.find(|l| l.ty == Some("application/activity+json".to_string()))
.unwrap();
let href = actor_link.href.as_ref().unwrap();
client.get(href)
.header("Accept", "application/activity+json")
.send()
.await.unwrap()
.json::<ap::Person>()
.await.unwrap()
}
#[derive(Debug)]
struct PostSignature {
date: String,
digest: String,
signature: String
}
#[derive(Debug)]
struct GetSignature {
date: String,
signature: String
}
fn sign_get_request(key_id: String, raw_url: String) -> GetSignature {
let url = Url::parse(&raw_url).unwrap();
let host = url.host_str().unwrap();
let path = url.path();
let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!("../../private.pem")).unwrap();
let signing_key = SigningKey::<Sha256>::new(private_key);
// UTC=GMT for our purposes, use it
// RFC7231 is hardcoded to use GMT for.. some reason
let ts = Utc::now();
// RFC7231 string
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
dbg!(&date);
let to_sign = format!("(request-target): get {}\nhost: {}\ndate: {}",
path,
host,
date);
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
let header = format!(
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{}\"",
key_id,
BASE64_STANDARD.encode(signature.to_bytes())
);
GetSignature {
date: date,
signature: header
}
}
fn sign_post_request(key_id: String, raw_url: String, body: String) -> PostSignature {
let url = Url::parse(&raw_url).unwrap();
let host = url.host_str().unwrap();
let path = url.path();
let private_key = RsaPrivateKey::from_pkcs8_pem(include_str!("../../private.pem")).unwrap();
let signing_key = SigningKey::<Sha256>::new(private_key);
let mut hasher = Sha256::new();
hasher.update(body);
let sha256 = hasher.finalize();
let b64 = BASE64_STANDARD.encode(sha256);
let digest = format!("SHA-256={}", b64);
// UTC=GMT for our purposes, use it
// RFC7231 is hardcoded to use GMT for.. some reason
let ts = Utc::now();
// RFC7231 string
let date = ts.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
dbg!(&date);
let to_sign = format!("(request-target): post {}\nhost: {}\ndate: {}\ndigest: {}",
path,
host,
date,
digest);
let signature = signing_key.sign_with_rng(&mut rand::rngs::OsRng, &to_sign.into_bytes());
let header = format!(
"keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{}\"",
key_id,
BASE64_STANDARD.encode(signature.to_bytes())
);
PostSignature {
date: date,
digest: digest,
signature: header
}
}
#[get("/test")]
async fn index() -> &'static str {
let client = reqwest::Client::new();
let user = resolve_user("amy@fedi.amy.mov", "fedi.amy.mov").await;
dbg!(&user);
let post = ap::CreateActivity {
id: "https://ferri.amy.mov/activities/amy/20".to_string(),
ty: "Create".to_string(),
summary: "Amy create a note".to_string(),
actor: "https://ferri.amy.mov/users/amy".to_string(),
object: ap::Post {
context: "https://www.w3.org/ns/activitystreams".to_string(),
id: "https://ferri.amy.mov/users/amy/posts/20".to_string(),
ty: "Note".to_string(),
content: "My first post".to_string(),
ts: "2025-04-10T10:48:11Z".to_string(),
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
},
ts: "2025-04-10T10:48:11Z".to_string(),
to: vec!["https://ferri.amy.mov/users/amy/followers".to_string()],
cc: vec![],
};
let key_id = "https://ferri.amy.mov/users/amy#main-key".to_string();
let document = serde_json::to_string(&post).unwrap();
let signature = sign_post_request(key_id, user.inbox.clone(), document);
dbg!(&signature);
let follow_res = client.post(user.inbox)
.header("Content-Type", "application/activity+json")
.header("Accept", "application/activity+json")
.header("Date", signature.date)
.header("Digest", signature.digest)
.header("Signature", signature.signature)
.json(&post)
.send()
.await.unwrap()
.text()
.await.unwrap();
println!("{}", follow_res);
"Hello, world!"
}
#[derive(Serialize, Deserialize, Debug, FromForm)]
#[serde(crate = "rocket::serde")]
struct Status {
status: String,
}
#[derive(Debug)]
struct AuthenticatedUser {
id: String
}
#[derive(Debug)]
enum LoginError {
InvalidData,
UsernameDoesNotExist,
WrongPassword
}
#[rocket::async_trait]
impl<'a> FromRequest<'a> for AuthenticatedUser {
type Error = LoginError;
async fn from_request(request: &'a Request<'_>) -> Outcome<AuthenticatedUser, LoginError> {
let token = request.headers().get_one("Authorization").unwrap();
Outcome::Success(AuthenticatedUser {
id: token.to_string()
})
}
}
#[post("/statuses", data="<status>")]
async fn new_status(mut db: Connection<Db>, status: Form<Status>, user: AuthenticatedUser) {
let user = FerriUser::by_name(&user.id, &mut **db).await;
let post_id = Uuid::new_v4();
let uri = format!("https://ferri.amy.mov/users/amy/posts/{}", post_id);
let post = sqlx::query!(r#"
INSERT INTO post (id, user_id, content)
VALUES (?1, ?2, ?3)
RETURNING *
"#, uri, user.id, status.status).fetch_one(&mut **db).await;
dbg!(user, status, post);
}
pub fn launch() -> Rocket<Build> {
build().attach(Db::init())
.mount("/", routes![
index,
inbox,
post_inbox,
outbox,
user,
user_profile,
get_post,
followers,
following,
activity,
webfinger
])
.mount("/api/v1", routes![
new_status
])
}

96
flake.lock generated Normal file
View file

@ -0,0 +1,96 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1743550720,
"narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c621e8422220273271f52058f618c94e405bb0f5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1743583204,
"narHash": "sha256-F7n4+KOIfWrwoQjXrL2wD9RhFYLs2/GGe/MQY1sSdlE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2c8d3f48d33929642c1c12cd243df4cc7d2ce434",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1743296961,
"narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1736320768,
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1743906877,
"narHash": "sha256-Thah1oU8Vy0gs9bh5QhNcQh1iuQiowMnZPbrkURonZA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "9d00c6b69408dd40d067603012938d9fbe95cfcd",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

40
flake.nix Normal file
View file

@ -0,0 +1,40 @@
{
description = "Description for the project";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
};
};
outputs = inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }: {
_module.args.pkgs = import inputs.nixpkgs {
inherit system;
overlays = [
(import inputs.rust-overlay)
];
};
devShells.default = pkgs.mkShell {
env = {
DATABASE_URL = "sqlite:ferri.db";
};
packages = with pkgs; [
sqlx-cli
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
];
};
};
};
}

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS actor
(
-- URI
id TEXT PRIMARY KEY NOT NULL,
inbox TEXT NOT NULL,
outbox TEXT NOT NULL
);

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS follow
(
-- Activity ID
id TEXT PRIMARY KEY NOT NULL,
follower_id TEXT NOT NULL,
followed_id TEXT NOT NULL,
FOREIGN KEY(follower_id) REFERENCES actor(id),
FOREIGN KEY(followed_id) REFERENCES actor(id)
);

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS user
(
-- Username
id TEXT PRIMARY KEY NOT NULL,
actor_id TEXT NOT NULL,
display_name TEXT NOT NULL,
FOREIGN KEY(actor_id) REFERENCES actor(id)
);

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS post
(
-- Uri
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES user(id)
);