feat: first pass at mastoapi stuff

This commit is contained in:
nullishamy 2025-04-12 11:27:03 +01:00
parent ce3a9bfb26
commit 244cb8b7e6
Signed by: amy
SSH key fingerprint: SHA256:WmV0uk6WgAQvDJlM8Ld4mFPHZo02CLXXP5VkwQ5xtyk
13 changed files with 368 additions and 83 deletions

View file

@ -47,6 +47,34 @@ impl User {
&self.actor
}
pub async fn from_id(
uuid: &str,
conn: impl sqlx::Executor<'_, Database = Sqlite>,
) -> User {
let user = sqlx::query!(
r#"
SELECT u.*, a.id as "actor_own_id", a.inbox, a.outbox
FROM user u
INNER JOIN actor a ON u.actor_id = a.id
WHERE u.id = ?1
"#,
uuid
)
.fetch_one(conn)
.await
.unwrap();
User {
id: user.id,
username: user.username,
actor: Actor {
id: user.actor_own_id,
inbox: user.inbox,
outbox: user.outbox,
},
display_name: user.display_name,
}
}
pub async fn from_username(
username: &str,
conn: impl sqlx::Executor<'_, Database = Sqlite>,

View file

@ -4,12 +4,15 @@ use rocket::{
FromForm, State,
form::Form,
post,
serde::{Deserialize, Serialize},
serde::{Deserialize, Serialize, json::Json},
};
use rocket_db_pools::Connection;
use uuid::Uuid;
use crate::timeline::TimelineStatus;
use crate::{AuthenticatedUser, Db, types::content};
use crate::api::user::CredentialAcount;
#[derive(Serialize, Deserialize, Debug, FromForm)]
#[serde(crate = "rocket::serde")]
pub struct Status {
@ -26,7 +29,7 @@ pub async fn new_status(
let user = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
let outbox = ap::Outbox::for_user(user.clone(), http);
let post_id = Uuid::new_v4();
let post_id = Uuid::new_v4().to_string();
let uri = format!(
"https://ferri.amy.mov/users/{}/posts/{}",
@ -38,10 +41,11 @@ pub async fn new_status(
let post = sqlx::query!(
r#"
INSERT INTO post (id, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4)
INSERT INTO post (id, uri, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING *
"#,
post_id,
uri,
id,
status.status,
@ -102,3 +106,137 @@ pub async fn new_status(
outbox.post(req).await;
}
}
#[post("/statuses", data = "<status>", rank = 2)]
pub async fn new_status_json(
mut db: Connection<Db>,
http: &State<HttpClient>,
status: Json<Status>,
user: AuthenticatedUser,
) -> Json<TimelineStatus> {
dbg!(&user);
let user = ap::User::from_id(&user.username, &mut **db).await;
let outbox = ap::Outbox::for_user(user.clone(), http);
let post_id = Uuid::new_v4().to_string();
let uri = format!(
"https://ferri.amy.mov/users/{}/posts/{}",
user.id(),
post_id
);
let id = user.id();
let now = Local::now().to_rfc3339();
let post = sqlx::query!(
r#"
INSERT INTO post (id, uri, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING *
"#,
post_id,
uri,
id,
status.status,
now
)
.fetch_one(&mut **db)
.await
.unwrap();
let actors = sqlx::query!("SELECT * FROM actor")
.fetch_all(&mut **db)
.await
.unwrap();
for record in actors {
// Don't send to ourselves
if &record.id == user.actor_id() {
continue
}
let create_id = format!("https://ferri.amy.mov/activities/{}", Uuid::new_v4());
let activity = ap::Activity {
id: create_id,
ty: ap::ActivityType::Create,
object: content::Post {
context: "https://www.w3.org/ns/activitystreams".to_string(),
id: uri.clone(),
content: status.status.clone(),
ty: "Note".to_string(),
ts: Local::now().to_rfc3339(),
to: vec![format!(
"https://ferri.amy.mov/users/{}/followers",
user.username()
)],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
},
to: vec![format!(
"https://ferri.amy.mov/users/{}/followers",
user.username()
)],
cc: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
..Default::default()
};
let actor = ap::Actor::from_raw(
record.id.clone(),
record.inbox.clone(),
record.outbox.clone(),
);
let req = ap::OutgoingActivity {
req: activity,
signed_by: format!("https://ferri.amy.mov/users/{}#main-key", user.username()),
to: actor,
};
req.save(&mut **db).await;
outbox.post(req).await;
}
let user_uri = format!(
"https://ferri.amy.mov/users/{}",
user.id(),
);
Json(TimelineStatus {
id: post.id.clone(),
created_at: post.created_at.clone(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: post.content.clone(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: post.uri.clone(),
url: post.uri.clone(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: user.id().to_string(),
username: user.username().to_string(),
acct: user.username().to_string(),
display_name: user.display_name().to_string(),
locked: false,
bot: false,
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
}
})
}

View file

@ -10,32 +10,33 @@ pub type TimelineAccount = CredentialAcount;
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct TimelineStatus {
id: String,
created_at: String,
in_reply_to_id: Option<String>,
in_reply_to_account_id: Option<String>,
content: String,
visibility: String,
spoiler_text: String,
sensitive: bool,
uri: String,
url: String,
replies_count: i64,
reblogs_count: i64,
favourites_count: i64,
favourited: bool,
reblogged: bool,
muted: bool,
bookmarked: bool,
media_attachments: Vec<()>,
account: TimelineAccount,
pub id: String,
pub created_at: String,
pub in_reply_to_id: Option<String>,
pub in_reply_to_account_id: Option<String>,
pub content: String,
pub visibility: String,
pub spoiler_text: String,
pub sensitive: bool,
pub uri: String,
pub url: String,
pub replies_count: i64,
pub reblogs_count: i64,
pub favourites_count: i64,
pub favourited: bool,
pub reblogged: bool,
pub muted: bool,
pub bookmarked: bool,
pub media_attachments: Vec<()>,
pub account: TimelineAccount,
}
#[get("/timelines/home?<limit>")]
pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus>> {
let posts = sqlx::query!(
r#"
SELECT p.id as "post_id", u.id as "user_id", p.content, u.username, u.display_name, u.actor_id FROM post p
SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", u.username, u.display_name, u.actor_id, p.created_at
FROM post p
INNER JOIN user u on p.user_id = u.id
"#
)
@ -45,17 +46,18 @@ pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus
let mut out = Vec::<TimelineStatus>::new();
for record in posts {
let user_uri = format!("https://ferri.amy.mov/users/{}", record.username);
out.push(TimelineStatus {
id: record.post_id.clone(),
created_at: "2025-04-10T22:12:09Z".to_string(),
created_at: record.created_at.clone(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: record.content.clone(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: record.post_id.clone(),
url: record.post_id.clone(),
uri: record.post_uri.clone(),
url: record.post_uri.clone(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
@ -65,7 +67,7 @@ pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: record.actor_id.clone(),
id: record.user_id.clone(),
username: record.username.clone(),
acct: record.username.clone(),
display_name: record.display_name.clone(),
@ -74,7 +76,7 @@ pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: record.actor_id.clone(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
@ -86,6 +88,6 @@ pub async fn home(mut db: Connection<Db>, limit: i64) -> Json<Vec<TimelineStatus
},
});
}
Json(out)
}

View file

@ -7,6 +7,7 @@ use rocket_db_pools::Connection;
use uuid::Uuid;
use crate::{AuthenticatedUser, Db, http::HttpClient};
use crate::timeline::{TimelineStatus, TimelineAccount};
#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
@ -44,10 +45,10 @@ pub async fn verify_credentials() -> Json<CredentialAcount> {
attribution_domains: vec![],
note: "".to_string(),
url: "https://ferri.amy.mov/@amy".to_string(),
avatar: "https://i.sstatic.net/l60Hf.png".to_string(),
avatar_static: "https://i.sstatic.net/l60Hf.png".to_string(),
header: "https://i.sstatic.net/l60Hf.png".to_string(),
header_static: "https://i.sstatic.net/l60Hf.png".to_string(),
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
@ -55,15 +56,15 @@ pub async fn verify_credentials() -> Json<CredentialAcount> {
})
}
#[post("/accounts/<account>/follow")]
#[post("/accounts/<uuid>/follow")]
pub async fn new_follow(
mut db: Connection<Db>,
http: &State<HttpClient>,
account: &str,
uuid: &str,
user: AuthenticatedUser,
) {
let follower = ap::User::from_actor_id(&user.actor_id, &mut **db).await;
let followed = ap::User::from_username(account, &mut **db).await;
let followed = ap::User::from_id(uuid, &mut **db).await;
let outbox = ap::Outbox::for_user(follower.clone(), http.inner());
@ -86,3 +87,98 @@ pub async fn new_follow(
req.save(&mut **db).await;
outbox.post(req).await;
}
#[get("/accounts/<uuid>")]
pub async fn account(mut db: Connection<Db>, uuid: &str, user: AuthenticatedUser) -> Json<TimelineAccount> {
let user = ap::User::from_id(uuid, &mut **db).await;
let user_uri = format!("https://ferri.amy.mov/users/{}", user.username());
Json(CredentialAcount {
id: user.id().to_string(),
username: user.username().to_string(),
acct: user.username().to_string(),
display_name: user.display_name().to_string(),
locked: false,
bot: false,
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
})
}
#[get("/accounts/<uuid>/statuses?<limit>")]
pub async fn statuses(
mut db: Connection<Db>,
uuid: &str,
limit: Option<i64>,
user: AuthenticatedUser,
) -> Json<Vec<TimelineStatus>> {
let user = ap::User::from_id(uuid, &mut **db).await;
let uid = user.id();
let posts = sqlx::query!(
r#"
SELECT p.id as "post_id", u.id as "user_id", p.content, p.uri as "post_uri", u.username, u.display_name, u.actor_id, p.created_at
FROM post p
INNER JOIN user u on p.user_id = u.id
WHERE u.id = ?1
"#, uid)
.fetch_all(&mut **db)
.await
.unwrap();
let mut out = Vec::<TimelineStatus>::new();
for record in posts {
let user_uri = format!("https://ferri.amy.mov/users/{}", record.username);
out.push(TimelineStatus {
id: record.post_id.clone(),
created_at: record.created_at.clone(),
in_reply_to_id: None,
in_reply_to_account_id: None,
content: record.content.clone(),
visibility: "public".to_string(),
spoiler_text: "".to_string(),
sensitive: false,
uri: record.post_uri.clone(),
url: record.post_uri.clone(),
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
media_attachments: vec![],
account: CredentialAcount {
id: record.user_id.clone(),
username: record.username.clone(),
acct: record.username.clone(),
display_name: record.display_name.clone(),
locked: false,
bot: false,
created_at: "2025-04-10T22:12:09Z".to_string(),
attribution_domains: vec![],
note: "".to_string(),
url: user_uri,
avatar: "https://ferri.amy.mov/assets/pfp.png".to_string(),
avatar_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header: "https://ferri.amy.mov/assets/pfp.png".to_string(),
header_static: "https://ferri.amy.mov/assets/pfp.png".to_string(),
followers_count: 1,
following_count: 1,
statuses_count: 1,
last_status_at: "2025-04-10T22:14:34Z".to_string(),
},
});
}
Json(out)
}

View file

@ -111,6 +111,13 @@ async fn handle_follow_activity(followed_account: String, activity: activity::Fo
outbox.post(req).await;
}
async fn handle_like_activity(activity: activity::LikeActivity, mut db: Connection<Db>) {
let target_post = sqlx::query!("SELECT * FROM post WHERE uri = ?1", activity.object)
.fetch_one(&mut **db)
.await.unwrap();
dbg!(&target_post);
}
async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpClient, mut db: Connection<Db>) {
assert!(&activity.object.ty == "Note");
let user = http
@ -128,21 +135,17 @@ async fn handle_create_activity(activity: activity::CreateActivity,http: &HttpCl
let user = ap::User::from_actor_id(&activity.actor, &mut **db).await;
let post_id = Uuid::new_v4();
let uri = format!(
"https://ferri.amy.mov/users/{}/posts/{}",
user.username(),
post_id
);
let id = user.id();
let user_id = user.id();
let now = Local::now().to_rfc3339();
let content = activity.object.content.clone();
let post_id = Uuid::new_v4().to_string();
let uri = activity.id;
sqlx::query!(r#"
INSERT INTO post (id, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4)
"#, uri, id, content, now)
INSERT INTO post (id, uri, user_id, content, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
"#, post_id, uri, user_id, content, now)
.execute(&mut **db)
.await.unwrap();
}
@ -163,6 +166,10 @@ pub async fn inbox(db: Connection<Db>, http: &State<HttpClient>, user: String, b
let activity = serde_json::from_str::<activity::CreateActivity>(&body).unwrap();
handle_create_activity(activity, http.inner(), db).await;
},
"Like" => {
let activity = serde_json::from_str::<activity::LikeActivity>(&body).unwrap();
handle_like_activity(activity, db).await;
},
unknown => {
eprintln!("WARN: Unknown activity '{}' - {}", unknown, body);
}

View file

@ -26,7 +26,7 @@ pub struct Token {
#[post("/oauth/token")]
pub async fn new_token() -> Json<Token> {
Json(Token {
access_token: "access-token".to_string(),
access_token: "9b9d497b-2731-435f-a929-e609ca69dac9".to_string(),
token_type: "Bearer".to_string(),
expires_in: 3600,
scope: "read write follow push".to_string(),

View file

@ -31,9 +31,9 @@ pub async fn outbox(user: String) -> Json<OrderedCollection> {
})
}
#[get("/users/<user>/followers")]
pub async fn followers(mut db: Connection<Db>, user: String) -> Json<OrderedCollection> {
let target = ap::User::from_username(&user, &mut **db).await;
#[get("/users/<uuid>/followers")]
pub async fn followers(mut db: Connection<Db>, uuid: &str) -> Json<OrderedCollection> {
let target = ap::User::from_id(uuid, &mut **db).await;
let actor_id = target.actor_id();
let followers = sqlx::query!(
@ -49,7 +49,7 @@ pub async fn followers(mut db: Connection<Db>, user: String) -> Json<OrderedColl
Json(OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Followers for {}", user),
summary: format!("Followers for {}", uuid),
total_items: 1,
ordered_items: followers
.into_iter()
@ -58,9 +58,9 @@ pub async fn followers(mut db: Connection<Db>, user: String) -> Json<OrderedColl
})
}
#[get("/users/<user>/following")]
pub async fn following(mut db: Connection<Db>, user: String) -> Json<OrderedCollection> {
let target = ap::User::from_username(&user, &mut **db).await;
#[get("/users/<uuid>/following")]
pub async fn following(mut db: Connection<Db>, uuid: &str) -> Json<OrderedCollection> {
let target = ap::User::from_id(uuid, &mut **db).await;
let actor_id = target.actor_id();
let following = sqlx::query!(
@ -76,7 +76,7 @@ pub async fn following(mut db: Connection<Db>, user: String) -> Json<OrderedColl
Json(OrderedCollection {
ty: "OrderedCollection".to_string(),
summary: format!("Following for {}", user),
summary: format!("Following for {}", uuid),
total_items: 1,
ordered_items: following
.into_iter()
@ -85,40 +85,47 @@ pub async fn following(mut db: Connection<Db>, user: String) -> Json<OrderedColl
})
}
#[get("/users/<user>/posts/<post>")]
pub async fn post(user: String, post: String) -> (ContentType, Json<content::Post>) {
#[get("/users/<uuid>/posts/<post>")]
pub async fn post(mut db: Connection<Db>, uuid: &str, post: String) -> (ContentType, Json<content::Post>) {
let post = sqlx::query!(r#"
SELECT * FROM post WHERE id = ?1
"#, post)
.fetch_one(&mut **db)
.await.unwrap();
(
activity_type(),
Json(content::Post {
id: format!("https://ferri.amy.mov/users/{}/posts/{}", user, post),
context: "https://www.w3.org/ns/activitystreams".to_string(),
id: format!("https://ferri.amy.mov/users/{}/posts/{}", uuid, post.id),
ty: "Note".to_string(),
content: "My first post".to_string(),
ts: "2025-04-10T10:48:11Z".to_string(),
content: post.content,
ts: post.created_at,
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>")]
pub async fn user(user: String) -> (ContentType, Json<Person>) {
#[get("/users/<uuid>")]
pub async fn user(mut db: Connection<Db>, uuid: &str) -> (ContentType, Json<Person>) {
let user = ap::User::from_id(uuid, &mut **db).await;
(
activity_type(),
Json(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),
id: user.id().to_string(),
name: user.username().to_string(),
preferred_username: user.display_name().to_string(),
followers: format!("https://ferri.amy.mov/users/{}/followers", uuid),
following: format!("https://ferri.amy.mov/users/{}/following", uuid),
summary: format!("ferri {}", user.username()),
inbox: format!("https://ferri.amy.mov/users/{}/inbox", uuid),
outbox: format!("https://ferri.amy.mov/users/{}/outbox", uuid),
public_key: Some(UserKey {
id: format!("https://ferri.amy.mov/users/{}#main-key", user),
owner: format!("https://ferri.amy.mov/users/{}", user),
id: format!("https://ferri.amy.mov/users/{}#main-key", uuid),
owner: format!("https://ferri.amy.mov/users/{}", uuid),
public_key: include_str!("../../../public.pem").to_string(),
}),
}),

View file

@ -25,7 +25,7 @@ pub async fn webfinger(mut db: Connection<Db>, resource: &str) -> Json<Webfinger
Json(WebfingerResponse {
subject: resource.to_string(),
aliases: vec![
format!("https://ferri.amy.mov/users/{}", user.username()),
format!("https://ferri.amy.mov/users/{}", user.id()),
format!("https://ferri.amy.mov/@{}", user.username()),
],
links: vec![
@ -37,8 +37,8 @@ pub async fn webfinger(mut db: Connection<Db>, resource: &str) -> Json<Webfinger
Link {
rel: "self".to_string(),
ty: Some("application/activity+json".to_string()),
href: Some(format!("https://ferri.amy.mov/users/{}", user.username())),
href: Some(format!("https://ferri.amy.mov/users/{}", user.id())),
},
],
})
}
}

View file

@ -41,6 +41,7 @@ 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();
let token = token.strip_prefix("Bearer").map(|s| s.trim()).unwrap_or(token);
Outcome::Success(AuthenticatedUser {
username: token.to_string(),
actor_id: format!("https://ferri.amy.mov/users/{}", token)
@ -80,7 +81,10 @@ pub fn launch() -> Rocket<Build> {
"/api/v1",
routes![
api::status::new_status,
api::status::new_status_json,
api::user::new_follow,
api::user::statuses,
api::user::account,
api::apps::new_app,
api::preferences::preferences,
api::user::verify_credentials,

View file

@ -10,9 +10,12 @@ pub struct MinimalActivity {
pub ty: String,
}
pub type DeleteActivity = BasicActivity;
pub type LikeActivity = BasicActivity;
#[derive(Serialize, Deserialize, Debug)]
#[serde(crate = "rocket::serde")]
pub struct DeleteActivity {
pub struct BasicActivity {
pub id: String,
#[serde(rename = "type")]
pub ty: String,
@ -56,4 +59,4 @@ pub struct AcceptActivity {
pub object: String,
pub actor: String,
}
}

View file

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

View file

@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS post
(
-- Uri
id TEXT PRIMARY KEY NOT NULL,
uri TEXT NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,

View file

@ -2,8 +2,8 @@ CREATE TABLE IF NOT EXISTS activity
(
-- UUID
id TEXT PRIMARY KEY NOT NULL,
ty TEXT NOT NULL,
actor_id TEXT NOT NULL,
ty TEXT NOT NULL,
actor_id TEXT NOT NULL,
FOREIGN KEY(actor_id) REFERENCES actor(id)
);