Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a8c9ea8c | ||
|
|
39fdfd3cde | ||
|
|
9e01b515e9 | ||
|
|
5ffc8122d0 |
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tg-archive-bot"
|
name = "tg-archive-bot"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
description = "Bot for archiving messages in TeenGovernment and related servers"
|
description = "Bot for archiving messages in TeenGovernment and related servers"
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ pub async fn git(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
|
|
||||||
let response = ctx.say("Processing...").await?;
|
let response = ctx.say("Processing...").await?;
|
||||||
|
|
||||||
let msg = "Git repo: https://git.ewenlau.net/ewenlau/tg-archive\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.";
|
let msg = "Git repo: https://git.ewenlau.net/ewenlau/tg-archive-bot\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.";
|
||||||
edit_response_message(&response, ctx, msg, true).await?;
|
edit_response_message(&response, ctx, msg, true).await?;
|
||||||
trace_message(
|
trace_message(
|
||||||
msg,
|
msg,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub async fn stats(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
);
|
);
|
||||||
let stats = web::archive_stats(&ctx.data().pool).await?;
|
let stats = web::archive_stats(&ctx.data().pool).await?;
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"# Archive Statistics\n## Messages\nArchived messages: {}\nArchived users: {}\nChannels: {}\nServers: {}\n## Storage usage\nTotal archive: {}\nMessages and metadata: {}\nAttachments: {}",
|
"# Archive Statistics\n## Messages\nArchived messages: {}\nArchived users: {}\nChannels: {}\nServers: {}\n## Storage usage\nArchived data: {}\nMessage content: {}\nAttachments: {}",
|
||||||
stats.messages,
|
stats.messages,
|
||||||
stats.users,
|
stats.users,
|
||||||
stats.channels,
|
stats.channels,
|
||||||
|
|||||||
+99
-27
@@ -12,6 +12,7 @@ use rand::{Rng, distr::Alphanumeric};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer, cookie::time::Duration};
|
use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer, cookie::time::Duration};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ tokio::task_local! {
|
|||||||
|
|
||||||
const ITEMS_PER_PAGE: i64 = 100;
|
const ITEMS_PER_PAGE: i64 = 100;
|
||||||
const SHOWN_PAGES: i64 = 10;
|
const SHOWN_PAGES: i64 = 10;
|
||||||
|
const CHANNEL_ACCESS_TTL_SECONDS: u64 = 5 * 60;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct WebData {
|
struct WebData {
|
||||||
@@ -38,6 +40,8 @@ struct WebUser {
|
|||||||
username: String,
|
username: String,
|
||||||
channel_ids: Vec<i64>,
|
channel_ids: Vec<i64>,
|
||||||
channel_access: Vec<ChannelAccess>,
|
channel_access: Vec<ChannelAccess>,
|
||||||
|
#[serde(default)]
|
||||||
|
channel_access_refreshed_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
@@ -603,7 +607,26 @@ async fn require_user(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match session.get::<WebUser>("user").await {
|
match session.get::<WebUser>("user").await {
|
||||||
Ok(Some(user)) => {
|
Ok(Some(mut user)) => {
|
||||||
|
if channel_access_needs_refresh(&user, request.uri().path()) {
|
||||||
|
let channel_access = match accessible_channels(&data, user.id).await {
|
||||||
|
Ok(channel_access) => channel_access,
|
||||||
|
Err(error) => {
|
||||||
|
error!("Discord permission refresh failed: {}", error);
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
user.channel_ids = channel_access
|
||||||
|
.iter()
|
||||||
|
.map(|access| access.channel_id)
|
||||||
|
.collect();
|
||||||
|
user.channel_access = channel_access;
|
||||||
|
user.channel_access_refreshed_at = unix_timestamp();
|
||||||
|
if let Err(error) = session.insert("user", &user).await {
|
||||||
|
error!("Could not update refreshed session permissions: {}", error);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
if !request_is_allowed(&data.pool, &user, request.uri().path()).await {
|
if !request_is_allowed(&data.pool, &user, request.uri().path()).await {
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
}
|
}
|
||||||
@@ -618,6 +641,34 @@ async fn require_user(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unix_timestamp() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_access_needs_refresh_at(user: &WebUser, path: &str, now: u64) -> bool {
|
||||||
|
let expired =
|
||||||
|
now.saturating_sub(user.channel_access_refreshed_at) >= CHANNEL_ACCESS_TTL_SECONDS;
|
||||||
|
let requested_channel_is_missing = path
|
||||||
|
.trim_matches('/')
|
||||||
|
.split('/')
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.as_slice()
|
||||||
|
.get(0..2)
|
||||||
|
.and_then(|parts| match parts {
|
||||||
|
["channels", id] => id.parse::<i64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.is_some_and(|channel_id| !user.channel_ids.contains(&channel_id));
|
||||||
|
expired || requested_channel_is_missing
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_access_needs_refresh(user: &WebUser, path: &str) -> bool {
|
||||||
|
channel_access_needs_refresh_at(user, path, unix_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
async fn request_is_allowed(pool: &PgPool, user: &WebUser, path: &str) -> bool {
|
async fn request_is_allowed(pool: &PgPool, user: &WebUser, path: &str) -> bool {
|
||||||
let parts = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
let parts = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
||||||
let channel_id = match parts.as_slice() {
|
let channel_id = match parts.as_slice() {
|
||||||
@@ -801,6 +852,7 @@ async fn discord_callback(
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
channel_ids,
|
channel_ids,
|
||||||
channel_access,
|
channel_access,
|
||||||
|
channel_access_refreshed_at: unix_timestamp(),
|
||||||
};
|
};
|
||||||
if session.cycle_id().await.is_err() {
|
if session.cycle_id().await.is_err() {
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
@@ -1072,32 +1124,14 @@ pub async fn archive_stats(pool: &PgPool) -> Result<ArchiveStats, sqlx::Error> {
|
|||||||
(SELECT COUNT(*) FROM discord_users),
|
(SELECT COUNT(*) FROM discord_users),
|
||||||
(SELECT COUNT(*) FROM guilds),
|
(SELECT COUNT(*) FROM guilds),
|
||||||
(SELECT COUNT(*) FROM channels),
|
(SELECT COUNT(*) FROM channels),
|
||||||
pg_total_relation_size('guilds')
|
(SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0)
|
||||||
+ pg_total_relation_size('channels')
|
FROM message_versions)
|
||||||
+ pg_total_relation_size('discord_users')
|
+ (SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0)
|
||||||
+ pg_total_relation_size('discord_roles')
|
FROM attachments),
|
||||||
+ pg_total_relation_size('messages')
|
(SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0)
|
||||||
+ pg_total_relation_size('message_versions')
|
FROM message_versions),
|
||||||
+ pg_total_relation_size('attachments')
|
(SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0)
|
||||||
+ pg_total_relation_size('embeds')
|
FROM attachments);",
|
||||||
+ pg_total_relation_size('embed_fields')
|
|
||||||
+ pg_total_relation_size('guild_history')
|
|
||||||
+ pg_total_relation_size('channel_history')
|
|
||||||
+ pg_total_relation_size('discord_user_history')
|
|
||||||
+ pg_total_relation_size('guild_users'),
|
|
||||||
pg_total_relation_size('messages')
|
|
||||||
+ pg_total_relation_size('message_versions')
|
|
||||||
+ pg_total_relation_size('guilds')
|
|
||||||
+ pg_total_relation_size('channels')
|
|
||||||
+ pg_total_relation_size('discord_users')
|
|
||||||
+ pg_total_relation_size('discord_roles')
|
|
||||||
+ pg_total_relation_size('embeds')
|
|
||||||
+ pg_total_relation_size('embed_fields')
|
|
||||||
+ pg_total_relation_size('guild_history')
|
|
||||||
+ pg_total_relation_size('channel_history')
|
|
||||||
+ pg_total_relation_size('discord_user_history')
|
|
||||||
+ pg_total_relation_size('guild_users'),
|
|
||||||
pg_total_relation_size('attachments');",
|
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -2655,6 +2689,44 @@ mod tests {
|
|||||||
assert_eq!(Theme::from_cookie(Some("unknown")).as_str(), "white");
|
assert_eq!(Theme::from_cookie(Some("unknown")).as_str(), "white");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refreshes_expired_or_legacy_channel_access() {
|
||||||
|
let mut user = WebUser {
|
||||||
|
id: 1,
|
||||||
|
username: "user".into(),
|
||||||
|
channel_ids: vec![10],
|
||||||
|
channel_access: Vec::new(),
|
||||||
|
channel_access_refreshed_at: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!channel_access_needs_refresh_at(&user, "/channels", 1_299));
|
||||||
|
assert!(channel_access_needs_refresh_at(&user, "/channels", 1_300));
|
||||||
|
user.channel_access_refreshed_at = 0;
|
||||||
|
assert!(channel_access_needs_refresh_at(&user, "/channels", 1_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refreshes_when_a_directly_requested_channel_is_not_cached() {
|
||||||
|
let user = WebUser {
|
||||||
|
id: 1,
|
||||||
|
username: "user".into(),
|
||||||
|
channel_ids: vec![10],
|
||||||
|
channel_access: Vec::new(),
|
||||||
|
channel_access_refreshed_at: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!channel_access_needs_refresh_at(
|
||||||
|
&user,
|
||||||
|
"/channels/10/messages",
|
||||||
|
1_001
|
||||||
|
));
|
||||||
|
assert!(channel_access_needs_refresh_at(
|
||||||
|
&user,
|
||||||
|
"/channels/20/messages",
|
||||||
|
1_001
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_page_contains_the_footer_and_theme_switcher() {
|
fn shared_page_contains_the_footer_and_theme_switcher() {
|
||||||
let html = render_page("Archive", "Content", true);
|
let html = render_page("Archive", "Content", true);
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
<p>Archived {{message_count}} messages from {{user_count}} users across {{channel_count}} channels on {{server_count}} servers.</p>
|
<p>Archived {{message_count}} messages from {{user_count}} users across {{channel_count}} channels on {{server_count}} servers.</p>
|
||||||
<h3>Storage usage</h3>
|
<h3>Storage usage</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Total archive</dt><dd>{{total_storage}}</dd>
|
<dt>Archived data</dt><dd>{{total_storage}}</dd>
|
||||||
<dt>Messages and metadata</dt><dd>{{message_storage}}</dd>
|
<dt>Message content</dt><dd>{{message_storage}}</dd>
|
||||||
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
{{ body|safe }}
|
{{ body|safe }}
|
||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p class="footer-legal">Source code available <a href="https://git.ewenlau.net/ewenlau/tg-archive">here</a>.<br>
|
<p class="footer-legal">Source code available <a href="https://git.ewenlau.net/ewenlau/tg-archive-bot">here</a>.<br>
|
||||||
Copyright © 2026 Ewi and contributors<br>
|
Copyright © 2026 Ewi and contributors<br>
|
||||||
This content is licensed under GPL-3.0.</p>
|
This content is licensed under GPL-3.0.</p>
|
||||||
<form class="footer-theme" method="post" action="/theme">
|
<form class="footer-theme" method="post" action="/theme">
|
||||||
|
|||||||
Reference in new issue
Block a user