add: circle stacks
This commit is contained in:
parent
50704d27a9
commit
56cea83933
27 changed files with 419 additions and 107 deletions
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
version = "7.0.0"
|
||||
version = "8.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
@ -11,7 +11,7 @@ tetratto-shared = { path = "../shared" }
|
|||
tetratto-l10n = { path = "../l10n" }
|
||||
serde_json = "1.0.140"
|
||||
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] }
|
||||
reqwest = { version = "0.12.19", features = ["json"] }
|
||||
reqwest = { version = "0.12.20", features = ["json"] }
|
||||
bitflags = "2.9.1"
|
||||
async-recursion = "1.1.1"
|
||||
md-5 = "0.10.6"
|
||||
|
|
|
@ -95,7 +95,7 @@ impl DataManager {
|
|||
let owner = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
let stacks = self.get_stacks_by_owner(data.owner).await?;
|
||||
let stacks = self.get_stacks_by_user(data.owner).await?;
|
||||
|
||||
if stacks.len() >= Self::MAXIMUM_FREE_DRAFTS {
|
||||
return Err(Error::MiscError(
|
||||
|
|
|
@ -5,7 +5,7 @@ use crate::model::auth::Notification;
|
|||
use crate::model::communities::{Poll, Question};
|
||||
use crate::model::communities_permissions::CommunityPermission;
|
||||
use crate::model::moderation::AuditLogEntry;
|
||||
use crate::model::stacks::StackSort;
|
||||
use crate::model::stacks::{StackMode, StackSort, UserStack};
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::User,
|
||||
|
@ -25,6 +25,7 @@ pub type FullPost = (
|
|||
Option<(User, Post)>,
|
||||
Option<(Question, User)>,
|
||||
Option<(Poll, bool, bool)>,
|
||||
Option<UserStack>,
|
||||
);
|
||||
|
||||
macro_rules! private_post_replying {
|
||||
|
@ -114,7 +115,7 @@ impl DataManager {
|
|||
poll_id: get!(x->13(i64)) as usize,
|
||||
title: get!(x->14(String)),
|
||||
is_open: get!(x->15(i32)) as i8 == 1,
|
||||
circle: get!(x->16(i64)) as usize,
|
||||
stack: get!(x->16(i64)) as usize,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -275,6 +276,39 @@ impl DataManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the stack of the given post (if some).
|
||||
///
|
||||
/// # Returns
|
||||
/// `(can view post, stack)`
|
||||
pub async fn get_post_stack(
|
||||
&self,
|
||||
seen_stacks: &mut HashMap<usize, UserStack>,
|
||||
post: &Post,
|
||||
as_user_id: usize,
|
||||
) -> (bool, Option<UserStack>) {
|
||||
if post.stack != 0 {
|
||||
if let Some(s) = seen_stacks.get(&post.stack) {
|
||||
(
|
||||
(s.owner == as_user_id) | s.users.contains(&as_user_id),
|
||||
Some(s.to_owned()),
|
||||
)
|
||||
} else {
|
||||
let s = match self.get_stack_by_id(post.stack).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (true, None),
|
||||
};
|
||||
|
||||
seen_stacks.insert(s.id, s.to_owned());
|
||||
(
|
||||
(s.owner == as_user_id) | s.users.contains(&as_user_id),
|
||||
Some(s.to_owned()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(true, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a vector of just posts with their owner as well.
|
||||
pub async fn fill_posts(
|
||||
&self,
|
||||
|
@ -288,12 +322,14 @@ impl DataManager {
|
|||
Option<(User, Post)>,
|
||||
Option<(Question, User)>,
|
||||
Option<(Poll, bool, bool)>,
|
||||
Option<UserStack>,
|
||||
)>,
|
||||
> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
let mut users: HashMap<usize, User> = HashMap::new();
|
||||
let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new();
|
||||
let mut seen_stacks: HashMap<usize, UserStack> = HashMap::new();
|
||||
let mut replying_posts: HashMap<usize, Post> = HashMap::new();
|
||||
|
||||
for post in posts {
|
||||
|
@ -304,12 +340,25 @@ impl DataManager {
|
|||
let owner = post.owner;
|
||||
|
||||
if let Some(ua) = users.get(&owner) {
|
||||
let (can_view, stack) = self
|
||||
.get_post_stack(
|
||||
&mut seen_stacks,
|
||||
&post,
|
||||
if let Some(ua) = user { ua.id } else { 0 },
|
||||
)
|
||||
.await;
|
||||
|
||||
if !can_view {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push((
|
||||
post.clone(),
|
||||
ua.clone(),
|
||||
self.get_post_reposting(&post, ignore_users, user).await,
|
||||
self.get_post_question(&post, ignore_users).await?,
|
||||
self.get_post_poll(&post, user).await?,
|
||||
stack,
|
||||
));
|
||||
} else {
|
||||
let ua = self.get_user_by_id(owner).await?;
|
||||
|
@ -357,6 +406,18 @@ impl DataManager {
|
|||
}
|
||||
}
|
||||
|
||||
let (can_view, stack) = self
|
||||
.get_post_stack(
|
||||
&mut seen_stacks,
|
||||
&post,
|
||||
if let Some(ua) = user { ua.id } else { 0 },
|
||||
)
|
||||
.await;
|
||||
|
||||
if !can_view {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ...
|
||||
users.insert(owner, ua.clone());
|
||||
out.push((
|
||||
|
@ -365,6 +426,7 @@ impl DataManager {
|
|||
self.get_post_reposting(&post, ignore_users, user).await,
|
||||
self.get_post_question(&post, ignore_users).await?,
|
||||
self.get_post_poll(&post, user).await?,
|
||||
stack,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -384,6 +446,7 @@ impl DataManager {
|
|||
|
||||
let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new();
|
||||
let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new();
|
||||
let mut seen_stacks: HashMap<usize, UserStack> = HashMap::new();
|
||||
let mut replying_posts: HashMap<usize, Post> = HashMap::new();
|
||||
|
||||
for post in posts {
|
||||
|
@ -395,6 +458,18 @@ impl DataManager {
|
|||
let community = post.community;
|
||||
|
||||
if let Some((ua, community)) = seen_before.get(&(owner, community)) {
|
||||
let (can_view, stack) = self
|
||||
.get_post_stack(
|
||||
&mut seen_stacks,
|
||||
&post,
|
||||
if let Some(ua) = user { ua.id } else { 0 },
|
||||
)
|
||||
.await;
|
||||
|
||||
if !can_view {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push((
|
||||
post.clone(),
|
||||
ua.clone(),
|
||||
|
@ -402,6 +477,7 @@ impl DataManager {
|
|||
self.get_post_reposting(&post, ignore_users, user).await,
|
||||
self.get_post_question(&post, ignore_users).await?,
|
||||
self.get_post_poll(&post, user).await?,
|
||||
stack,
|
||||
));
|
||||
} else {
|
||||
let ua = self.get_user_by_id(owner).await?;
|
||||
|
@ -440,6 +516,18 @@ impl DataManager {
|
|||
}
|
||||
}
|
||||
|
||||
let (can_view, stack) = self
|
||||
.get_post_stack(
|
||||
&mut seen_stacks,
|
||||
&post,
|
||||
if let Some(ua) = user { ua.id } else { 0 },
|
||||
)
|
||||
.await;
|
||||
|
||||
if !can_view {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ...
|
||||
let community = self.get_community_by_id(community).await?;
|
||||
seen_before.insert((owner, community.id), (ua.clone(), community.clone()));
|
||||
|
@ -450,6 +538,7 @@ impl DataManager {
|
|||
self.get_post_reposting(&post, ignore_users, user).await,
|
||||
self.get_post_question(&post, ignore_users).await?,
|
||||
self.get_post_poll(&post, user).await?,
|
||||
stack,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -933,6 +1022,37 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all posts from the given stack (from most recent).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the stack the requested posts belong to
|
||||
/// * `batch` - the limit of posts in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_posts_by_stack(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<Post>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM posts WHERE stack = $1 AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_post_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("post".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all pinned posts from the given community (from most recent).
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -1370,7 +1490,30 @@ impl DataManager {
|
|||
}
|
||||
}
|
||||
|
||||
let community = self.get_community_by_id(data.community).await?;
|
||||
// check stack
|
||||
if data.stack != 0 {
|
||||
let stack = self.get_stack_by_id(data.stack).await?;
|
||||
|
||||
if stack.mode != StackMode::Circle {
|
||||
return Err(Error::MiscError(
|
||||
"You must use a \"Circle\" stack for this".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if stack.owner != data.owner && !stack.users.contains(&data.owner) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let community = if data.stack != 0 {
|
||||
// if we're posting to a stack, the community should always be the town square
|
||||
data.community = self.0.0.town_square;
|
||||
self.get_community_by_id(self.0.0.town_square).await?
|
||||
} else {
|
||||
// otherwise, load whatever community the post is requesting
|
||||
self.get_community_by_id(data.community).await?
|
||||
};
|
||||
|
||||
// check values (if this isn't reposting something else)
|
||||
let is_reposting = if let Some(ref repost) = data.context.repost {
|
||||
|
@ -1466,6 +1609,10 @@ impl DataManager {
|
|||
};
|
||||
|
||||
if let Some(ref rt) = reposting {
|
||||
if rt.stack != data.stack && rt.stack != 0 {
|
||||
return Err(Error::MiscError("Cannot repost out of stack".to_string()));
|
||||
}
|
||||
|
||||
if data.content.is_empty() {
|
||||
// reposting but NOT quoting... we shouldn't be able to repost a direct repost
|
||||
data.context.reposts_enabled = false;
|
||||
|
@ -1507,7 +1654,7 @@ impl DataManager {
|
|||
|
||||
// send notification
|
||||
// this would look better if rustfmt didn't give up on this line
|
||||
if owner.id != rt.owner && !owner.settings.private_profile {
|
||||
if owner.id != rt.owner && !owner.settings.private_profile && data.stack == 0 {
|
||||
self.create_notification(
|
||||
Notification::new(
|
||||
format!(
|
||||
|
@ -1631,7 +1778,7 @@ impl DataManager {
|
|||
&(data.poll_id as i64),
|
||||
&data.title,
|
||||
&{ if data.is_open { 1 } else { 0 } },
|
||||
&(data.circle as i64),
|
||||
&(data.stack as i64),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -1781,7 +1928,7 @@ impl DataManager {
|
|||
let res = execute!(
|
||||
&conn,
|
||||
"UPDATE posts SET is_deleted = $1 WHERE id = $2",
|
||||
params![if is_deleted { 1 } else { 0 }, &(id as i64)]
|
||||
params![&if is_deleted { 1 } else { 0 }, &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
|
@ -1793,7 +1940,9 @@ impl DataManager {
|
|||
if is_deleted {
|
||||
// decr parent comment count
|
||||
if let Some(replying_to) = y.replying_to {
|
||||
self.decr_post_comments(replying_to).await.unwrap();
|
||||
if replying_to != 0 {
|
||||
self.decr_post_comments(replying_to).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// decr user post count
|
||||
|
@ -1893,7 +2042,7 @@ impl DataManager {
|
|||
let res = execute!(
|
||||
&conn,
|
||||
"UPDATE posts SET is_open = $1 WHERE id = $2",
|
||||
params![if is_open { 1 } else { 0 }, &(id as i64)]
|
||||
params![&if is_open { 1 } else { 0 }, &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
|
@ -2091,5 +2240,5 @@ impl DataManager {
|
|||
auto_method!(decr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);
|
||||
|
||||
auto_method!(incr_post_comments() -> "UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr);
|
||||
auto_method!(decr_post_comments() -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);
|
||||
auto_method!(decr_post_comments()@get_post_by_id -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr=comment_count);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
use oiseau::cache::Cache;
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::User,
|
||||
permissions::FinePermission,
|
||||
stacks::{StackPrivacy, UserStack, StackMode, StackSort},
|
||||
communities::{Community, Poll, Post, Question},
|
||||
use crate::{
|
||||
database::posts::FullPost,
|
||||
model::{
|
||||
auth::User,
|
||||
permissions::FinePermission,
|
||||
stacks::{StackMode, StackPrivacy, StackSort, UserStack},
|
||||
Error, Result,
|
||||
},
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
|
||||
|
@ -37,16 +39,7 @@ impl DataManager {
|
|||
page: usize,
|
||||
ignore_users: &Vec<usize>,
|
||||
user: &Option<User>,
|
||||
) -> Result<
|
||||
Vec<(
|
||||
Post,
|
||||
User,
|
||||
Community,
|
||||
Option<(User, Post)>,
|
||||
Option<(Question, User)>,
|
||||
Option<(Poll, bool, bool)>,
|
||||
)>,
|
||||
> {
|
||||
) -> Result<Vec<FullPost>> {
|
||||
let stack = self.get_stack_by_id(id).await?;
|
||||
|
||||
Ok(match stack.mode {
|
||||
|
@ -89,6 +82,19 @@ impl DataManager {
|
|||
"You should use `get_stack_users` for this type".to_string(),
|
||||
));
|
||||
}
|
||||
StackMode::Circle => {
|
||||
if !stack.users.contains(&as_user_id) && as_user_id != stack.owner {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
self.fill_posts_with_community(
|
||||
self.get_posts_by_stack(stack.id, batch, page).await?,
|
||||
as_user_id,
|
||||
&ignore_users,
|
||||
user,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -119,9 +125,11 @@ impl DataManager {
|
|||
|
||||
/// Get all stacks by user.
|
||||
///
|
||||
/// Also pulls stacks that are of "Circle" type AND the user is added to the `users` list.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch stacks for
|
||||
pub async fn get_stacks_by_owner(&self, id: usize) -> Result<Vec<UserStack>> {
|
||||
pub async fn get_stacks_by_user(&self, id: usize) -> Result<Vec<UserStack>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -129,8 +137,8 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM stacks WHERE owner = $1 ORDER BY name ASC",
|
||||
&[&(id as i64)],
|
||||
"SELECT * FROM stacks WHERE owner = $1 OR (mode = '\"Circle\"' AND users LIKE $2) ORDER BY name ASC",
|
||||
&[&(id as i64), &format!("%{id}%")],
|
||||
|x| { Self::get_stack_from_row(x) }
|
||||
);
|
||||
|
||||
|
@ -142,6 +150,7 @@ impl DataManager {
|
|||
}
|
||||
|
||||
const MAXIMUM_FREE_STACKS: usize = 5;
|
||||
pub const MAXIMUM_FREE_STACK_USERS: usize = 50;
|
||||
|
||||
/// Create a new stack in the database.
|
||||
///
|
||||
|
@ -159,7 +168,7 @@ impl DataManager {
|
|||
let owner = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
let stacks = self.get_stacks_by_owner(data.owner).await?;
|
||||
let stacks = self.get_stacks_by_user(data.owner).await?;
|
||||
|
||||
if stacks.len() >= Self::MAXIMUM_FREE_STACKS {
|
||||
return Err(Error::MiscError(
|
||||
|
@ -216,6 +225,25 @@ impl DataManager {
|
|||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete stackblocks
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM stackblocks WHERE stack = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete posts
|
||||
let res = execute!(&conn, "DELETE FROM posts WHERE stack = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.0.1.remove(format!("atto.stack:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -260,10 +260,10 @@ pub struct Post {
|
|||
pub title: String,
|
||||
/// If the post is "open". Posts can act as tickets in a forge community.
|
||||
pub is_open: bool,
|
||||
/// The ID of the circle this post belongs to. 0 means no circle is connected.
|
||||
/// The ID of the stack this post belongs to. 0 means no stack is connected.
|
||||
///
|
||||
/// If circle is not 0, community should be 0 (and vice versa).
|
||||
pub circle: usize,
|
||||
/// If stack is not 0, community should be 0 (and vice versa).
|
||||
pub stack: usize,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
|
@ -291,7 +291,7 @@ impl Post {
|
|||
poll_id,
|
||||
title: String::new(),
|
||||
is_open: true,
|
||||
circle: 0,
|
||||
stack: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -76,8 +76,6 @@ pub enum AppScope {
|
|||
UserCreateCommunities,
|
||||
/// Create stacks on behalf of the user.
|
||||
UserCreateStacks,
|
||||
/// Create circles on behalf of the user.
|
||||
UserCreateCircles,
|
||||
/// Delete posts owned by the user.
|
||||
UserDeletePosts,
|
||||
/// Delete messages owned by the user.
|
||||
|
@ -108,8 +106,6 @@ pub enum AppScope {
|
|||
UserManageRequests,
|
||||
/// Manage the user's uploads.
|
||||
UserManageUploads,
|
||||
/// Manage the user's circles (add/remove users or delete).
|
||||
UserManageCircles,
|
||||
/// Edit posts created by the user.
|
||||
UserEditPosts,
|
||||
/// Edit drafts created by the user.
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StackPrivacy {
|
||||
/// Can be viewed by anyone.
|
||||
Public,
|
||||
|
@ -15,7 +15,7 @@ impl Default for StackPrivacy {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StackMode {
|
||||
/// `users` vec contains ID of users to INCLUDE into the timeline;
|
||||
/// every other user is excluded
|
||||
|
@ -28,6 +28,8 @@ pub enum StackMode {
|
|||
///
|
||||
/// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`).
|
||||
BlockList,
|
||||
/// `users` vec contains ID of users who are allowed to view posts posted to the stack.
|
||||
Circle,
|
||||
}
|
||||
|
||||
impl Default for StackMode {
|
||||
|
@ -36,7 +38,7 @@ impl Default for StackMode {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StackSort {
|
||||
Created,
|
||||
Likes,
|
||||
|
@ -48,7 +50,7 @@ impl Default for StackSort {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UserStack {
|
||||
pub id: usize,
|
||||
pub created: usize,
|
||||
|
@ -76,7 +78,7 @@ impl UserStack {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct StackBlock {
|
||||
pub id: usize,
|
||||
pub created: usize,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue