add: developer panel

This commit is contained in:
trisua 2025-06-14 20:26:54 -04:00
parent ebded00fd3
commit 39574df691
44 changed files with 982 additions and 84 deletions

View file

@ -1,9 +1,10 @@
use oiseau::cache::Cache;
use crate::model::{
Error, Result,
auth::User,
permissions::FinePermission,
apps::{AppQuota, ThirdPartyApp},
auth::User,
oauth::AppScope,
permissions::FinePermission,
Error, Result,
};
use crate::{auto_method, DataManager};
@ -31,6 +32,7 @@ impl DataManager {
quota_status: serde_json::from_str(&get!(x->6(String))).unwrap(),
banned: get!(x->7(i32)) as i8 == 1,
grants: get!(x->8(i32)) as usize,
scopes: serde_json::from_str(&get!(x->9(String))).unwrap(),
}
}
@ -95,7 +97,7 @@ impl DataManager {
let res = execute!(
&conn,
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
params![
&(data.id as i64),
&(data.created as i64),
@ -105,7 +107,8 @@ impl DataManager {
&data.redirect,
&serde_json::to_string(&data.quota_status).unwrap(),
&{ if data.banned { 1 } else { 0 } },
&(data.grants as i32)
&(data.grants as i32),
&serde_json::to_string(&data.scopes).unwrap(),
]
);
@ -144,7 +147,8 @@ impl DataManager {
auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --incr);
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $2" --cache-key-tmpl="atto.app:{}" --decr=grants);
auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr);
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants);
}

View file

@ -114,7 +114,11 @@ impl DataManager {
///
/// # Arguments
/// * `token` - the token of the user
pub async fn get_user_by_grant_token(&self, token: &str) -> Result<(AuthGrant, User)> {
pub async fn get_user_by_grant_token(
&self,
token: &str,
check_expiration: bool,
) -> Result<(AuthGrant, User)> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
@ -122,7 +126,7 @@ impl DataManager {
let res = query_row!(
&conn,
"SELECT * FROM users WHERE grants::jsonb @> ('{\"token\":' || $1 || '}')::jsonb",
"SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)",
&[&token],
|x| Ok(Self::get_user_from_row(x))
);
@ -132,14 +136,25 @@ impl DataManager {
}
let user = res.unwrap();
Ok((
user.grants
.iter()
.find(|x| x.token == token)
.unwrap()
.clone(),
user,
))
let grant = user
.grants
.iter()
.find(|x| x.token == token)
.unwrap()
.clone();
// check token expiry
if check_expiration {
let now = unix_epoch_timestamp();
let delta = now - grant.last_updated;
if delta > 604_800_000 {
return Err(Error::MiscError("Token expired".to_string()));
}
}
// ...
Ok((grant, user))
}
/// Create a new user in the database.

View file

@ -312,7 +312,7 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
// add journal page owner as admin
// add community owner as admin
self.create_membership(CommunityMembership::new(
data.owner,
data.id,

View file

@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS apps (
redirect TEXT NOT NULL,
quota_status TEXT NOT NULL,
banned INT NOT NULL,
grants INT NOT NULL
grants INT NOT NULL,
scopes TEXT NOT NULL
)

View file

@ -11,7 +11,7 @@ use oiseau::PostgresRow;
use oiseau::{execute, get, query_row, params};
impl DataManager {
/// Get a [`UserBlock`] from an SQL row.
/// Get an [`IpBlock`] from an SQL row.
pub(crate) fn get_ipblock_from_row(
#[cfg(feature = "sqlite")] x: &SqliteRow<'_>,
#[cfg(feature = "postgres")] x: &PostgresRow,
@ -79,7 +79,7 @@ impl DataManager {
/// Create a new user block in the database.
///
/// # Arguments
/// * `data` - a mock [`UserBlock`] object to insert
/// * `data` - a mock [`IpBlock`] object to insert
pub async fn create_ipblock(&self, data: IpBlock) -> Result<()> {
let conn = match self.0.connect().await {
Ok(c) => c,

View file

@ -19,7 +19,7 @@ use oiseau::PostgresRow;
use oiseau::{execute, get, query_row, query_rows, params};
impl DataManager {
/// Get a [`JournalEntry`] from an SQL row.
/// Get a [`CommunityMembership`] from an SQL row.
pub(crate) fn get_membership_from_row(
#[cfg(feature = "sqlite")] x: &SqliteRow<'_>,
#[cfg(feature = "postgres")] x: &PostgresRow,

View file

@ -76,7 +76,7 @@ impl DataManager {
// get poll and check permission
let poll = self.get_poll_by_id(data.poll_id).await?;
let now = unix_epoch_timestamp() as usize;
let now = unix_epoch_timestamp();
let diff = now - poll.created;
if diff > poll.expires {

View file

@ -269,7 +269,7 @@ impl DataManager {
if post.poll_id != 0 {
Ok(Some(match self.get_poll_by_id(post.poll_id).await {
Ok(p) => {
let expired = (unix_epoch_timestamp() as usize) - p.created > p.expires;
let expired = unix_epoch_timestamp() - p.created > p.expires;
(
p,
self.get_pollvote_by_owner_poll(user.id, post.poll_id)
@ -2022,7 +2022,7 @@ impl DataManager {
}
// update context
y.context.edited = unix_epoch_timestamp() as usize;
y.context.edited = unix_epoch_timestamp();
self.update_post_context(id, user, y.context).await?;
// return
@ -2075,7 +2075,7 @@ impl DataManager {
}
// update context
y.context.edited = unix_epoch_timestamp() as usize;
y.context.edited = unix_epoch_timestamp();
self.update_post_context(id, user, y.context).await?;
// return

View file

@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
use crate::model::oauth::AppScope;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum AppQuota {
@ -33,6 +34,37 @@ pub struct ThirdPartyApp {
/// Upon accepting a grant request, the user will be redirected to this URL
/// with a query parameter named `token`, which should be saved by the app
/// for future authentication.
///
/// The developer dashboard lists the URL you should send users to in order to
/// create a grant on their account in the information section under the label
/// "Grant URL".
///
/// Any search parameters sent with your grant URL (such as an internal user ID)
/// will also be sent back when the user is redirected to your redirect URL.
///
/// You can use this behaviour to keep track of what user you should save the grant
/// token under.
///
/// 1. Redirect user to grant URL with their ID: `{grant_url}?my_app_user_id={id}`
/// 2. In your redirect endpoint, read that ID and the added `token` parameter to
/// store the `token` under the given `my_app_user_id`
///
/// The redirect URL will also have a `verifier` search parameter appended.
/// This verifier is required to refresh the grant's token (which is what is
/// used in the `Atto-Grant` cookie).
///
/// Tokens only last a week after they were generated (with the verifier),
/// but you can refresh them by sending a request to:
/// `{tetratto}/api/v1/auth/user/{user_id}/grants/{app_id}/refresh`.
///
/// Tetratto will generate the verifier and challenge for you. The challenge
/// is an SHA-256 hashed + base64 url encoded version of the verifier. This means
/// if the verifier doesn't match, it won't pass the challenge.
///
/// Requests to API endpoints using your grant token should be sent with a
/// cookie (in the `Cookie` header) named `Atto-Grant`. This cookie should
/// contain the token you received from either the initial connection,
/// or a token refresh.
pub redirect: String,
/// The app's quota status, which determines how many grants the app is allowed to maintain.
pub quota_status: AppQuota,
@ -40,6 +72,15 @@ pub struct ThirdPartyApp {
pub banned: bool,
/// The number of accepted grants the app maintains.
pub grants: usize,
/// The scopes used for every grant the app maintains.
///
/// These scopes are only cloned into **new** grants created for the app.
/// An app *cannot* change scopes and have them affect users who already have the
/// app connected. Users must delete the app's grant and authenticate it again
/// to update their scopes.
///
/// Your app should handle informing users when scopes change.
pub scopes: Vec<AppScope>,
}
impl ThirdPartyApp {
@ -47,7 +88,7 @@ impl ThirdPartyApp {
pub fn new(title: String, owner: usize, homepage: String, redirect: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
title,
homepage,
@ -55,6 +96,7 @@ impl ThirdPartyApp {
quota_status: AppQuota::Limited,
banned: false,
grants: 0,
scopes: Vec::new(),
}
}
}

View file

@ -251,7 +251,7 @@ impl User {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
username,
password,
salt,
@ -262,7 +262,7 @@ impl User {
notification_count: 0,
follower_count: 0,
following_count: 0,
last_seen: unix_epoch_timestamp() as usize,
last_seen: unix_epoch_timestamp(),
totp: String::new(),
recovery_codes: Vec::new(),
post_count: 0,
@ -312,7 +312,7 @@ impl User {
(
ip.to_string(),
tetratto_shared::hash::hash(unhashed),
unix_epoch_timestamp() as usize,
unix_epoch_timestamp(),
),
)
}
@ -460,7 +460,7 @@ impl Notification {
pub fn new(title: String, content: String, owner: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
title,
content,
owner,
@ -483,7 +483,7 @@ impl UserFollow {
pub fn new(initiator: usize, receiver: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
initiator,
receiver,
}
@ -511,7 +511,7 @@ impl UserBlock {
pub fn new(initiator: usize, receiver: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
initiator,
receiver,
}
@ -531,7 +531,7 @@ impl IpBlock {
pub fn new(initiator: usize, receiver: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
initiator,
receiver,
}
@ -551,7 +551,7 @@ impl IpBan {
pub fn new(ip: String, moderator: usize, reason: String) -> Self {
Self {
ip,
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
reason,
moderator,
}
@ -572,7 +572,7 @@ impl UserWarning {
pub fn new(user: usize, moderator: usize, content: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
receiver: user,
moderator,
content,

View file

@ -33,7 +33,7 @@ impl Channel {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
community,
owner,
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
position,
@ -78,7 +78,7 @@ pub struct Message {
impl Message {
pub fn new(channel: usize, owner: usize, content: String) -> Self {
let now = unix_epoch_timestamp() as usize;
let now = unix_epoch_timestamp();
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),

View file

@ -33,7 +33,7 @@ impl Community {
pub fn new(title: String, owner: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
title: title.clone(),
context: CommunityContext {
display_name: title,
@ -157,7 +157,7 @@ impl CommunityMembership {
pub fn new(owner: usize, community: usize, role: CommunityPermission) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
community,
role,
@ -273,7 +273,7 @@ impl Post {
) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
content,
owner,
community,
@ -353,7 +353,7 @@ impl Question {
) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
receiver,
content,
@ -387,7 +387,7 @@ impl PostDraft {
pub fn new(content: String, owner: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
content,
owner,
}
@ -426,7 +426,7 @@ impl Poll {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
owner,
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
expires,
// options
option_a,
@ -491,7 +491,7 @@ impl PollVote {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
owner,
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
poll_id,
vote,
}

View file

@ -5,7 +5,7 @@ use serde::{
};
bitflags! {
/// Fine-grained journal permissions built using bitwise operations.
/// Fine-grained community permissions built using bitwise operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CommunityPermission: u32 {
const DEFAULT = 1 << 0;

View file

@ -16,7 +16,7 @@ impl AuditLogEntry {
pub fn new(moderator: usize, content: String) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
moderator,
content,
}
@ -38,7 +38,7 @@ impl Report {
pub fn new(owner: usize, content: String, asset: usize, asset_type: AssetType) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
content,
asset,

View file

@ -5,7 +5,6 @@ use super::{Result, Error};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthGrant {
pub id: usize,
/// The ID of the application associated with this grant.
pub app: usize,
/// The code challenge for PKCE verifiers associated with this grant.
@ -22,6 +21,9 @@ pub struct AuthGrant {
/// The access token associated with the account. This is **not** the same as
/// regular account access tokens, as the token can only be used with the requested `scopes`.
pub token: String,
/// The time in which the token was last refreshed. Tokens should stop being
/// accepted after a week has passed since this time.
pub last_updated: usize,
/// Scopes define what the grant's token is actually allowed to do.
///
/// No scope shall ever be allowed to change scopes or manage grants on behalf of the user.

View file

@ -29,7 +29,7 @@ impl Reaction {
pub fn new(owner: usize, asset: usize, asset_type: AssetType, is_like: bool) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
asset,
asset_type,

View file

@ -33,7 +33,7 @@ impl ActionRequest {
pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
action_type,
linked_asset,
@ -44,7 +44,7 @@ impl ActionRequest {
pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self {
Self {
id,
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
action_type,
linked_asset,

View file

@ -60,7 +60,7 @@ impl UserStack {
pub fn new(name: String, owner: usize, users: Vec<usize>) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
name,
users,

View file

@ -48,7 +48,7 @@ impl MediaUpload {
pub fn new(what: MediaType, owner: usize) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
what,
}
@ -108,7 +108,7 @@ impl CustomEmoji {
) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(),
created: unix_epoch_timestamp() as usize,
created: unix_epoch_timestamp(),
owner,
community,
upload_id,