From 48408cba613883d70affbb30a40d30fa8ed1d6ef Mon Sep 17 00:00:00 2001
From: Elias Wendland <193786789+eliaswen@users.noreply.github.com>
Date: Tue, 25 Aug 2026 21:58:43 +0200
Subject: [PATCH] Release 0.2.0
---
Cargo.toml | 6 +
README.md | 2 +
askama.toml | 2 +
docker-compose.example.yml | 3 +
src/commands/mod.rs | 2 +
src/commands/stats.rs | 33 +
src/main.rs | 35 +-
src/web.rs | 1844 +++++++++++++++++++-----
static/arbitrary-page.html | 7 +-
static/attachment-value.html | 1 -
static/attachment.html | 2 +-
static/channel-status.html | 25 +-
static/channel.html | 2 +-
static/current-page.html | 2 +-
static/embed-description.html | 1 -
static/embed-title.html | 1 -
static/embed-url.html | 1 -
static/embed.html | 2 +-
static/error.html | 2 +-
static/index.html | 10 +-
static/list.html | 10 +-
static/login.html | 6 +
static/message-list-item.html | 2 +-
static/message-list.html | 10 +-
static/message-search-form.html | 8 +-
static/message-version-button.html | 4 +-
static/message-version-navigation.html | 8 +-
static/message.html | 19 +-
static/page-button.html | 7 +-
static/page.html | 35 +-
static/pagination.html | 8 +-
static/search-form.html | 4 +-
static/server-icon.html | 2 +-
static/server-status.html | 28 +-
static/server.html | 2 +-
static/status-avatar.html | 2 +-
static/status-channel.html | 2 +-
static/status-icon.html | 2 +-
static/status-name.html | 2 +-
static/status-server.html | 2 +-
static/status-user.html | 2 +-
static/theme.css | 209 +++
static/user-status.html | 29 +-
static/user.html | 2 +-
44 files changed, 1874 insertions(+), 514 deletions(-)
create mode 100644 askama.toml
create mode 100644 src/commands/stats.rs
delete mode 100644 static/attachment-value.html
delete mode 100644 static/embed-description.html
delete mode 100644 static/embed-title.html
delete mode 100644 static/embed-url.html
create mode 100644 static/login.html
create mode 100644 static/theme.css
diff --git a/Cargo.toml b/Cargo.toml
index 49f69e4..6c03016 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,11 +10,17 @@ repository = "https://git.ewenlau.net/ewenlau/tg-archive-bot"
homepage = "https://git.ewenlau.net/ewenlau/tg-archive-bot"
[dependencies]
+askama = "0.14"
axum = { version = "0.8.4", features = ["form"] }
+axum-extra = { version = "0.12.6", features = ["cookie"] }
dotenv = "0.15.0"
poise = "0.6.2"
+rand = "0.9"
+reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1.52.3", features = ["full"] }
+tower-sessions = "0.15"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+urlencoding = "2"
diff --git a/README.md b/README.md
index 1769665..13d4057 100644
--- a/README.md
+++ b/README.md
@@ -5,3 +5,5 @@ This is the bot meant for archiving messages in TeenGovernment and related serve
The bot archives new messages and edits, including attachments and embeds. Messages stay in the archive if they are deleted from Discord.
An image is available at git.ewenlau.net/ewenlau/tg-archive-bot:latest for the stable version and git.ewenlau.net/ewenlau/tg-archive-bot:dev for the development (or testing) version.
+
+The web archive uses Discord OAuth2 with the `identify` scope. Add `/login/discord/callback` as a redirect in the Discord developer portal, then set `TG_BOT_DISCORD_CLIENT_ID`, `TG_BOT_DISCORD_CLIENT_SECRET`, and `TG_BOT_DISCORD_REDIRECT_URI`.
diff --git a/askama.toml b/askama.toml
new file mode 100644
index 0000000..db3374b
--- /dev/null
+++ b/askama.toml
@@ -0,0 +1,2 @@
+[general]
+dirs = ["static"]
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index 6c80c34..db255ba 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -31,6 +31,9 @@ services:
- postgres
environment:
TG_BOT_DISCORD_TOKEN: YOUR_DISCORD_TOKEN_HERE
+ TG_BOT_DISCORD_CLIENT_ID: YOUR_DISCORD_CLIENT_ID_HERE
+ TG_BOT_DISCORD_CLIENT_SECRET: YOUR_DISCORD_CLIENT_SECRET_HERE
+ TG_BOT_DISCORD_REDIRECT_URI: http://localhost:3000/login/discord/callback
TG_BOT_DATABASE_URL: postgres://postgres:YOUR_POSTGRES_PASSWORD_HERE@postgres:5432/tg_archive_bot
TG_BOT_LOG: info
TG_BOT_WEB_ADDRESS: 0.0.0.0:3000
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index f766281..3479d1e 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -1,7 +1,9 @@
pub mod git;
pub mod ping;
+pub mod stats;
pub mod version;
pub use git::git;
pub use ping::ping;
+pub use stats::stats;
pub use version::version;
diff --git a/src/commands/stats.rs b/src/commands/stats.rs
new file mode 100644
index 0000000..51610b4
--- /dev/null
+++ b/src/commands/stats.rs
@@ -0,0 +1,33 @@
+use crate::messaging::trace_message;
+use crate::{Context, Error, web};
+use tracing::{debug, trace};
+
+#[doc = "Show archive statistics"]
+#[poise::command(slash_command, prefix_command)]
+pub async fn stats(ctx: Context<'_>) -> Result<(), Error> {
+ trace!(
+ "stats command called by user {} in guild {}",
+ ctx.author().id.get(),
+ ctx.guild_id().unwrap().get()
+ );
+ let stats = web::archive_stats(&ctx.data().pool).await?;
+ let msg = format!(
+ "# Archive Statistics\n## Messages\nArchived messages: {}\nArchived users: {}\nChannels: {}\nServers: {}\n## Storage usage\nTotal archive: {}\nMessages and metadata: {}\nAttachments: {}",
+ stats.messages,
+ stats.users,
+ stats.channels,
+ stats.servers,
+ web::format_bytes(stats.total_storage),
+ web::format_bytes(stats.message_storage),
+ web::format_bytes(stats.attachment_storage),
+ );
+ trace_message(
+ &msg,
+ ctx.channel_id().to_string(),
+ ctx.guild_id().unwrap().to_string(),
+ )
+ .await;
+ ctx.say(msg).await?;
+ debug!("Stats command performed for user {}", ctx.author().name);
+ Ok(())
+}
diff --git a/src/main.rs b/src/main.rs
index c75f34c..6fd4fba 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,7 +12,6 @@ pub struct Data {
pub pool: sqlx::PgPool,
}
-// Define standard types for Poise context, commands, and errors.
pub type Error = Box;
pub type Context<'a> = poise::Context<'a, Data, Error>;
@@ -37,7 +36,6 @@ async fn main() {
trace!("Env loaded");
- // Grab envs
trace!("Loading database url");
let database_url =
env::var("TG_BOT_DATABASE_URL").expect("Expected a database url in the environment");
@@ -51,9 +49,15 @@ async fn main() {
let web_address = env::var("TG_BOT_WEB_ADDRESS").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
trace!("Web address loaded");
+ let discord_client_id = env::var("TG_BOT_DISCORD_CLIENT_ID")
+ .expect("Expected a Discord client ID in the environment");
+ let discord_client_secret = env::var("TG_BOT_DISCORD_CLIENT_SECRET")
+ .expect("Expected a Discord client secret in the environment");
+ let discord_redirect_uri = env::var("TG_BOT_DISCORD_REDIRECT_URI")
+ .expect("Expected a Discord redirect URI in the environment");
+
info!("Starting bot...");
- // Initialize database
debug!("Initalizing database");
let pool = connect_database(&database_url)
.await
@@ -71,7 +75,14 @@ async fn main() {
let web_listener = tokio::net::TcpListener::bind(&web_address)
.await
.expect("Failed to bind web server");
- tokio::spawn(web::run(web_listener, pool.clone()));
+ tokio::spawn(web::run(
+ web_listener,
+ pool.clone(),
+ token.clone(),
+ discord_client_id,
+ discord_client_secret,
+ discord_redirect_uri,
+ ));
debug!("Web server started");
trace!("Loading intents");
@@ -102,7 +113,12 @@ async fn main() {
}
})
},
- commands: vec![commands::ping(), commands::version(), commands::git()],
+ commands: vec![
+ commands::ping(),
+ commands::version(),
+ commands::git(),
+ commands::stats(),
+ ],
..Default::default()
})
.setup(|ctx, _ready, framework| {
@@ -171,5 +187,12 @@ async fn connect_database(database_url: &str) -> Result {
}
fn quote_identifier(identifier: &str) -> String {
- format!("\"{}\"", identifier.replace('"', "\"\""))
+ let mut escaped = String::with_capacity(identifier.len());
+ for character in identifier.chars() {
+ escaped.push(character);
+ if character == '"' {
+ escaped.push(character);
+ }
+ }
+ format!("\"{}\"", escaped)
}
diff --git a/src/web.rs b/src/web.rs
index 9adc78f..b9ef2e6 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -1,56 +1,441 @@
+use askama::Template;
use axum::{
- Form, Router,
- extract::{Path, Query, State},
- http::{StatusCode, header},
- response::{Html, IntoResponse, Response},
+ Extension, Form, Router,
+ extract::{Path, Query, Request, State},
+ http::{HeaderMap, StatusCode, header},
+ middleware::{self, Next},
+ response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
-use serde::Deserialize;
+use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
+use rand::{Rng, distr::Alphanumeric};
+use serde::{Deserialize, Serialize};
use sqlx::PgPool;
+use std::collections::{HashMap, HashSet};
+use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer, cookie::time::Duration};
use tracing::{error, info};
+tokio::task_local! {
+ static ACTIVE_THEME: Theme;
+}
+
const ITEMS_PER_PAGE: i64 = 100;
const SHOWN_PAGES: i64 = 10;
-const PAGE_TEMPLATE: &str = include_str!("../static/page.html");
-const INDEX_TEMPLATE: &str = include_str!("../static/index.html");
-const LIST_TEMPLATE: &str = include_str!("../static/list.html");
-const SEARCH_FORM_TEMPLATE: &str = include_str!("../static/search-form.html");
-const MESSAGE_SEARCH_FORM_TEMPLATE: &str = include_str!("../static/message-search-form.html");
-const SERVER_TEMPLATE: &str = include_str!("../static/server.html");
-const SERVER_ICON_TEMPLATE: &str = include_str!("../static/server-icon.html");
-const CHANNEL_TEMPLATE: &str = include_str!("../static/channel.html");
-const USER_TEMPLATE: &str = include_str!("../static/user.html");
-const SERVER_STATUS_TEMPLATE: &str = include_str!("../static/server-status.html");
-const CHANNEL_STATUS_TEMPLATE: &str = include_str!("../static/channel-status.html");
-const USER_STATUS_TEMPLATE: &str = include_str!("../static/user-status.html");
-const STATUS_NAME_TEMPLATE: &str = include_str!("../static/status-name.html");
-const STATUS_ICON_TEMPLATE: &str = include_str!("../static/status-icon.html");
-const STATUS_AVATAR_TEMPLATE: &str = include_str!("../static/status-avatar.html");
-const STATUS_CHANNEL_TEMPLATE: &str = include_str!("../static/status-channel.html");
-const STATUS_SERVER_TEMPLATE: &str = include_str!("../static/status-server.html");
-const STATUS_USER_TEMPLATE: &str = include_str!("../static/status-user.html");
-const MESSAGE_LIST_TEMPLATE: &str = include_str!("../static/message-list.html");
-const MESSAGE_LIST_ITEM_TEMPLATE: &str = include_str!("../static/message-list-item.html");
-const MESSAGE_TEMPLATE: &str = include_str!("../static/message.html");
-const MESSAGE_VERSION_NAVIGATION_TEMPLATE: &str =
- include_str!("../static/message-version-navigation.html");
-const MESSAGE_VERSION_BUTTON_TEMPLATE: &str = include_str!("../static/message-version-button.html");
-const ATTACHMENT_TEMPLATE: &str = include_str!("../static/attachment.html");
-const ATTACHMENT_VALUE_TEMPLATE: &str = include_str!("../static/attachment-value.html");
-const EMBED_TEMPLATE: &str = include_str!("../static/embed.html");
-const EMBED_TITLE_TEMPLATE: &str = include_str!("../static/embed-title.html");
-const EMBED_DESCRIPTION_TEMPLATE: &str = include_str!("../static/embed-description.html");
-const EMBED_URL_TEMPLATE: &str = include_str!("../static/embed-url.html");
-const PAGINATION_TEMPLATE: &str = include_str!("../static/pagination.html");
-const PAGE_BUTTON_TEMPLATE: &str = include_str!("../static/page-button.html");
-const CURRENT_PAGE_TEMPLATE: &str = include_str!("../static/current-page.html");
-const ARBITRARY_PAGE_TEMPLATE: &str = include_str!("../static/arbitrary-page.html");
-const ERROR_TEMPLATE: &str = include_str!("../static/error.html");
-
#[derive(Clone)]
struct WebData {
pool: PgPool,
+ http: reqwest::Client,
+ bot_token: String,
+ client_id: String,
+ client_secret: String,
+ redirect_uri: String,
+}
+
+#[derive(Clone, Deserialize, Serialize)]
+struct WebUser {
+ id: i64,
+ username: String,
+ channel_ids: Vec,
+ channel_access: Vec,
+}
+
+#[derive(Clone, Deserialize, Serialize)]
+struct ChannelAccess {
+ channel_id: i64,
+ reason: String,
+}
+
+impl WebUser {
+ fn access_reason(&self, channel_id: i64) -> &str {
+ self.channel_access
+ .iter()
+ .find(|access| access.channel_id == channel_id)
+ .map(|access| access.reason.as_str())
+ .unwrap_or("you are a member of a server containing it")
+ }
+}
+
+#[derive(Template)]
+#[template(path = "page.html")]
+struct PageTemplate<'a> {
+ title: &'a str,
+ body: &'a str,
+ theme: &'a str,
+ logged_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "login.html")]
+struct LoginTemplate;
+
+#[derive(Clone, Copy)]
+enum Theme {
+ White,
+ Black,
+ Oled,
+}
+
+impl Theme {
+ fn from_cookie(value: Option<&str>) -> Self {
+ match value {
+ Some("black") => Self::Black,
+ Some("oled") => Self::Oled,
+ _ => Self::White,
+ }
+ }
+
+ fn as_str(self) -> &'static str {
+ match self {
+ Self::White => "white",
+ Self::Black => "black",
+ Self::Oled => "oled",
+ }
+ }
+}
+
+#[derive(Deserialize)]
+struct ThemeForm {
+ theme: String,
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+struct IndexTemplate {
+ message_count: i64,
+ user_count: i64,
+ server_count: i64,
+ channel_count: i64,
+ total_storage: String,
+ message_storage: String,
+ attachment_storage: String,
+}
+
+#[derive(Template)]
+#[template(path = "list.html")]
+struct ListTemplate<'a> {
+ title: &'a str,
+ search_form: String,
+ item_count: i64,
+ item_name: &'a str,
+ items: String,
+ pagination: String,
+}
+
+#[derive(Template)]
+#[template(path = "search-form.html")]
+struct SearchFormTemplate<'a> {
+ action: &'a str,
+ label: &'a str,
+ search: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "server.html")]
+struct ServerTemplate<'a> {
+ guild_id: i64,
+ guild_name: &'a str,
+ icon: String,
+}
+
+#[derive(Template)]
+#[template(path = "server-icon.html")]
+struct ServerIconTemplate<'a> {
+ icon_url: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "channel.html")]
+struct ChannelTemplate<'a> {
+ channel_id: i64,
+ channel_name: &'a str,
+ server_id: i64,
+ server_name: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "user.html")]
+struct UserTemplate<'a> {
+ discord_id: i64,
+ discord_username: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "server-status.html")]
+struct ServerStatusTemplate<'a> {
+ server_name: &'a str,
+ server_id: i64,
+ message_count: i64,
+ user_count: i64,
+ channel_count: i64,
+ version_count: i64,
+ total_storage: String,
+ message_storage: String,
+ attachment_storage: String,
+ names: String,
+ icons: String,
+ channels: String,
+ users: String,
+}
+
+#[derive(Template)]
+#[template(path = "channel-status.html")]
+struct ChannelStatusTemplate<'a> {
+ channel_name: &'a str,
+ channel_id: i64,
+ server_id: i64,
+ server_name: &'a str,
+ message_count: i64,
+ user_count: i64,
+ version_count: i64,
+ total_storage: String,
+ message_storage: String,
+ attachment_storage: String,
+ names: String,
+ users: String,
+ access_reason: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "user-status.html")]
+struct UserStatusTemplate<'a> {
+ username: &'a str,
+ user_id: i64,
+ message_count: i64,
+ server_count: i64,
+ channel_count: i64,
+ version_count: i64,
+ total_storage: String,
+ message_storage: String,
+ attachment_storage: String,
+ names: String,
+ avatars: String,
+ servers: String,
+ channels: String,
+ access_reason: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "status-name.html")]
+struct StatusNameTemplate<'a> {
+ name: &'a str,
+ first_seen: &'a str,
+ last_seen: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "status-icon.html")]
+struct StatusIconTemplate<'a> {
+ icon_url: &'a str,
+ first_seen: &'a str,
+ last_seen: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "status-avatar.html")]
+struct StatusAvatarTemplate<'a> {
+ avatar_url: &'a str,
+ first_seen: &'a str,
+ last_seen: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "status-channel.html")]
+struct StatusChannelTemplate<'a> {
+ channel_id: i64,
+ channel_name: &'a str,
+ message_count: i64,
+}
+
+#[derive(Template)]
+#[template(path = "status-server.html")]
+struct StatusServerTemplate<'a> {
+ server_id: i64,
+ server_name: &'a str,
+ message_count: i64,
+}
+
+#[derive(Template)]
+#[template(path = "status-user.html")]
+struct StatusUserTemplate<'a> {
+ user_id: i64,
+ username: &'a str,
+ message_count: i64,
+}
+
+#[derive(Template)]
+#[template(path = "message-list.html")]
+struct MessageListTemplate<'a> {
+ title: &'a str,
+ search_form: String,
+ item_count: i64,
+ items: String,
+ pagination: String,
+}
+
+#[derive(Template)]
+#[template(path = "message-list-item.html")]
+struct MessageListItemTemplate<'a> {
+ message_id: i64,
+ author_id: i64,
+ author: &'a str,
+ server_id: i64,
+ server: &'a str,
+ channel_id: i64,
+ channel: &'a str,
+ timestamp: &'a str,
+ attachment_count: i64,
+ embed_count: i64,
+}
+
+#[derive(Template)]
+#[template(path = "message.html")]
+struct MessageTemplate<'a> {
+ message_id: i64,
+ author: &'a str,
+ author_id: i64,
+ server_id: i64,
+ server: &'a str,
+ channel_id: i64,
+ channel: &'a str,
+ timestamp: &'a str,
+ version_navigation: String,
+ content: &'a str,
+ attachments: String,
+ embeds: String,
+ access_reason: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "message-version-navigation.html")]
+struct MessageVersionNavigationTemplate<'a> {
+ message_id: i64,
+ current_version: i64,
+ version_count: i64,
+ archived_at: &'a str,
+ previous_version: String,
+ next_version: String,
+}
+
+#[derive(Template)]
+#[template(path = "message-version-button.html")]
+struct MessageVersionButtonTemplate<'a> {
+ message_id: i64,
+ version: i64,
+ label: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "attachment.html")]
+struct AttachmentTemplate<'a> {
+ attachment_id: i64,
+ message_version: i64,
+ filename: &'a str,
+ size: i64,
+ content_type: Option<&'a str>,
+ description: Option<&'a str>,
+}
+
+#[derive(Template)]
+#[template(path = "embed.html")]
+struct EmbedTemplate<'a> {
+ title: Option<&'a str>,
+ description: Option<&'a str>,
+ url: Option<&'a str>,
+}
+
+#[derive(Template)]
+#[template(path = "pagination.html")]
+struct PaginationTemplate {
+ first_pages: String,
+ arbitrary_page: String,
+ final_pages: String,
+ current_page: i64,
+ total_pages: i64,
+}
+
+#[derive(Template)]
+#[template(path = "page-button.html")]
+struct PageButtonTemplate<'a> {
+ action: &'a str,
+ search: &'a str,
+ search_by: Option<&'a str>,
+ page_number: i64,
+ label: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "current-page.html")]
+struct CurrentPageTemplate {
+ page_number: i64,
+}
+
+#[derive(Template)]
+#[template(path = "arbitrary-page.html")]
+struct ArbitraryPageTemplate<'a> {
+ action: &'a str,
+ search: &'a str,
+ search_by: Option<&'a str>,
+ total_pages: i64,
+}
+
+#[derive(Template)]
+#[template(path = "message-search-form.html")]
+struct MessageSearchFormTemplate<'a> {
+ action: &'a str,
+ search: &'a str,
+ content_selected: &'a str,
+ timestamp_selected: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+struct ErrorTemplate<'a> {
+ message: &'a str,
+}
+
+#[derive(Deserialize)]
+struct OAuthQuery {
+ code: String,
+ state: String,
+}
+
+#[derive(Deserialize)]
+struct OAuthToken {
+ access_token: String,
+}
+
+#[derive(Deserialize)]
+struct DiscordUser {
+ id: String,
+ username: String,
+}
+
+#[derive(Deserialize)]
+struct DiscordMember {
+ roles: Vec,
+}
+
+#[derive(Deserialize)]
+struct DiscordRole {
+ id: String,
+ name: String,
+ permissions: String,
+}
+
+#[derive(Deserialize)]
+struct DiscordChannel {
+ id: String,
+ permission_overwrites: Option>,
+}
+
+#[derive(Deserialize)]
+struct DiscordOverwrite {
+ id: String,
+ #[serde(rename = "type")]
+ kind: u8,
+ allow: String,
+ deny: String,
}
#[derive(Default, Deserialize)]
@@ -68,14 +453,14 @@ struct AttachmentQuery {
version: Option,
}
-struct ArchiveStats {
- messages: i64,
- users: i64,
- servers: i64,
- channels: i64,
- total_storage: i64,
- message_storage: i64,
- attachment_storage: i64,
+pub struct ArchiveStats {
+ pub messages: i64,
+ pub users: i64,
+ pub servers: i64,
+ pub channels: i64,
+ pub total_storage: i64,
+ pub message_storage: i64,
+ pub attachment_storage: i64,
}
#[derive(Default, Deserialize)]
@@ -144,8 +529,23 @@ impl MessageScope {
type WebResult = Result, (StatusCode, Html)>;
-pub async fn run(listener: tokio::net::TcpListener, pool: PgPool) {
- let app = Router::new()
+pub async fn run(
+ listener: tokio::net::TcpListener,
+ pool: PgPool,
+ bot_token: String,
+ client_id: String,
+ client_secret: String,
+ redirect_uri: String,
+) {
+ let data = WebData {
+ pool,
+ http: reqwest::Client::new(),
+ bot_token,
+ client_id,
+ client_secret,
+ redirect_uri,
+ };
+ let archive = Router::new()
.route("/", get(index))
.route("/servers", get(servers).post(search_servers))
.route("/servers/{server_id}", get(server))
@@ -168,7 +568,22 @@ pub async fn run(listener: tokio::net::TcpListener, pool: PgPool) {
.route("/messages", get(messages).post(search_messages))
.route("/messages/{message_id}", get(message))
.route("/attachments/{attachment_id}", get(attachment))
- .with_state(WebData { pool });
+ .route_layer(middleware::from_fn_with_state(data.clone(), require_user));
+ let sessions = SessionManagerLayer::new(MemoryStore::default())
+ .with_secure(!data.redirect_uri.starts_with("http://"))
+ .with_same_site(tower_sessions::cookie::SameSite::Lax)
+ .with_expiry(Expiry::OnInactivity(Duration::hours(12)));
+ let app = Router::new()
+ .route("/login", get(login))
+ .route("/login/discord", get(discord_login))
+ .route("/login/discord/callback", get(discord_callback))
+ .route("/logout", axum::routing::post(logout))
+ .route("/theme", axum::routing::post(set_theme))
+ .route("/theme.css", get(theme_css))
+ .merge(archive)
+ .with_state(data)
+ .layer(middleware::from_fn(theme_request))
+ .layer(sessions);
info!("Web server listening on {}", listener.local_addr().unwrap());
if let Err(error) = axum::serve(listener, app).await {
@@ -176,7 +591,481 @@ pub async fn run(listener: tokio::net::TcpListener, pool: PgPool) {
}
}
+async fn theme_request(jar: CookieJar, request: Request, next: Next) -> Response {
+ let theme = Theme::from_cookie(jar.get("theme").map(Cookie::value));
+ ACTIVE_THEME.scope(theme, next.run(request)).await
+}
+
+async fn require_user(
+ State(data): State,
+ session: Session,
+ mut request: Request,
+ next: Next,
+) -> Response {
+ match session.get::("user").await {
+ Ok(Some(user)) => {
+ if !request_is_allowed(&data.pool, &user, request.uri().path()).await {
+ return StatusCode::NOT_FOUND.into_response();
+ }
+ request.extensions_mut().insert(user);
+ next.run(request).await
+ }
+ Ok(None) => Redirect::to("/login").into_response(),
+ Err(error) => {
+ error!("Session error: {}", error);
+ StatusCode::INTERNAL_SERVER_ERROR.into_response()
+ }
+ }
+}
+
+async fn request_is_allowed(pool: &PgPool, user: &WebUser, path: &str) -> bool {
+ let parts = path.trim_matches('/').split('/').collect::>();
+ let channel_id = match parts.as_slice() {
+ ["channels", id, ..] => id.parse::().ok(),
+ ["messages", id] => match id.parse::() {
+ Ok(id) => sqlx::query_scalar("SELECT channel_id FROM messages WHERE message_id = $1")
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+ .ok()
+ .flatten(),
+ Err(_) => None,
+ },
+ ["attachments", id] => match id.parse::() {
+ Ok(id) => sqlx::query_scalar(
+ "SELECT m.channel_id FROM attachments a JOIN messages m ON m.message_id = a.message_id WHERE a.attachment_id = $1",
+ )
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+ .ok()
+ .flatten(),
+ Err(_) => None,
+ },
+ ["servers", id, ..] => return match id.parse::() {
+ Ok(id) => sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(SELECT 1 FROM channels WHERE guild_id = $1 AND channel_id = ANY($2))",
+ )
+ .bind(id)
+ .bind(&user.channel_ids)
+ .fetch_one(pool)
+ .await
+ .unwrap_or(false),
+ Err(_) => false,
+ },
+ ["users", id, ..] => return match id.parse::() {
+ Ok(id) => sqlx::query_scalar::<_, bool>(
+ "SELECT EXISTS(SELECT 1 FROM messages WHERE author_id = $1 AND channel_id = ANY($2))",
+ )
+ .bind(id)
+ .bind(&user.channel_ids)
+ .fetch_one(pool)
+ .await
+ .unwrap_or(false),
+ Err(_) => false,
+ },
+ _ => return true,
+ };
+ channel_id.is_some_and(|channel_id| user.channel_ids.contains(&channel_id))
+}
+
+async fn login(session: Session) -> Response {
+ match session.get::("user").await {
+ Ok(Some(_)) => Redirect::to("/").into_response(),
+ Ok(None) => Html(render_page(
+ "Log in",
+ &render_template(&LoginTemplate),
+ false,
+ ))
+ .into_response(),
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ }
+}
+
+async fn set_theme(
+ State(data): State,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Form(form): Form,
+) -> Response {
+ let theme = Theme::from_cookie(Some(&form.theme));
+ let cookie = Cookie::build(("theme", theme.as_str()))
+ .path("/")
+ .http_only(true)
+ .same_site(SameSite::Lax)
+ .secure(!data.redirect_uri.starts_with("http://"))
+ .max_age(Duration::days(365))
+ .build();
+ let return_to = headers
+ .get(header::REFERER)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| reqwest::Url::parse(value).ok())
+ .map(|url| {
+ let mut path = url.path().to_string();
+ if let Some(query) = url.query() {
+ path.push('?');
+ path.push_str(query);
+ }
+ path
+ })
+ .unwrap_or_else(|| "/".into());
+ (jar.add(cookie), Redirect::to(&return_to)).into_response()
+}
+
+async fn theme_css() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ include_str!("../static/theme.css"),
+ )
+}
+
+async fn discord_login(State(data): State, session: Session) -> Response {
+ let state: String = rand::rng()
+ .sample_iter(&Alphanumeric)
+ .take(48)
+ .map(char::from)
+ .collect();
+ if session.insert("oauth_state", &state).await.is_err() {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ }
+ let url = format!(
+ "https://discord.com/oauth2/authorize?response_type=code&client_id={}&scope=identify&state={}&redirect_uri={}",
+ data.client_id,
+ state,
+ urlencoding::encode(&data.redirect_uri),
+ );
+ Redirect::to(&url).into_response()
+}
+
+async fn discord_callback(
+ State(data): State,
+ session: Session,
+ Query(query): Query,
+) -> Response {
+ let state = session.remove::("oauth_state").await;
+ if !matches!(state, Ok(Some(state)) if state == query.state) {
+ return (StatusCode::BAD_REQUEST, "Invalid OAuth state").into_response();
+ }
+ let token = data
+ .http
+ .post("https://discord.com/api/v10/oauth2/token")
+ .form(&[
+ ("client_id", data.client_id.as_str()),
+ ("client_secret", data.client_secret.as_str()),
+ ("grant_type", "authorization_code"),
+ ("code", query.code.as_str()),
+ ("redirect_uri", data.redirect_uri.as_str()),
+ ])
+ .send()
+ .await;
+ let Ok(token) = token else {
+ return oauth_error("Discord token request failed");
+ };
+ let Ok(token) = token.error_for_status() else {
+ return oauth_error("Discord rejected the login request");
+ };
+ let Ok(token) = token.json::().await else {
+ return oauth_error("Discord returned an invalid token response");
+ };
+ let user = data
+ .http
+ .get("https://discord.com/api/v10/users/@me")
+ .bearer_auth(&token.access_token)
+ .send()
+ .await;
+ let Ok(user) = user else {
+ return oauth_error("Discord user request failed");
+ };
+ let Ok(user) = user.error_for_status() else {
+ return oauth_error("Discord rejected the user request");
+ };
+ let Ok(user) = user.json::().await else {
+ return oauth_error("Discord returned an invalid user response");
+ };
+ let Ok(user_id) = user.id.parse::() else {
+ return oauth_error("Discord returned an invalid user ID");
+ };
+ let channel_access = match accessible_channels(&data, user_id).await {
+ Ok(channel_access) => channel_access,
+ Err(error) => {
+ error!("Discord permission check failed: {}", error);
+ return oauth_error("Could not check your current Discord permissions");
+ }
+ };
+ let channel_ids = channel_access
+ .iter()
+ .map(|access| access.channel_id)
+ .collect();
+ let user = WebUser {
+ id: user_id,
+ username: user.username,
+ channel_ids,
+ channel_access,
+ };
+ if session.cycle_id().await.is_err() {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ }
+ if session.insert("user", user).await.is_err() {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ }
+ Redirect::to("/").into_response()
+}
+
+async fn logout(session: Session) -> Redirect {
+ let _ = session.delete().await;
+ Redirect::to("/login")
+}
+
+fn oauth_error(message: &str) -> Response {
+ (StatusCode::BAD_GATEWAY, message.to_string()).into_response()
+}
+
+async fn accessible_channels(
+ data: &WebData,
+ user_id: i64,
+) -> Result, Box> {
+ let guilds = sqlx::query_as::<_, (i64, String)>(
+ "SELECT guild_id, guild_name FROM guilds ORDER BY guild_id;",
+ )
+ .fetch_all(&data.pool)
+ .await?;
+ let bot = discord_get::(data, "/users/@me").await?;
+ let bot_id = bot.id.parse::()?;
+ let archived_channels = sqlx::query_scalar::<_, i64>("SELECT channel_id FROM channels;")
+ .fetch_all(&data.pool)
+ .await?
+ .into_iter()
+ .collect::>();
+ let mut visible = Vec::new();
+
+ for (guild_id, guild_name) in guilds {
+ let user = discord_get_optional::(
+ data,
+ &format!("/guilds/{}/members/{}", guild_id, user_id),
+ )
+ .await?;
+ let Some(user) = user else {
+ continue;
+ };
+ let bot = discord_get_optional::(
+ data,
+ &format!("/guilds/{}/members/{}", guild_id, bot_id),
+ )
+ .await?;
+ let Some(bot) = bot else {
+ continue;
+ };
+ let roles =
+ discord_get::>(data, &format!("/guilds/{}/roles", guild_id)).await?;
+ let channels =
+ discord_get::>(data, &format!("/guilds/{}/channels", guild_id))
+ .await?;
+ let role_names = roles
+ .iter()
+ .map(|role| Ok((role.id.parse::()?, role.name.clone())))
+ .collect::, Box>>()?;
+ let role_permissions = roles
+ .iter()
+ .map(|role| Ok((role.id.parse::()?, role.permissions.parse::()?)))
+ .collect::, Box>>()?;
+ let user_roles = user
+ .roles
+ .iter()
+ .map(|role| role.parse::())
+ .collect::, _>>()?;
+ let bot_roles = bot
+ .roles
+ .iter()
+ .map(|role| role.parse::())
+ .collect::, _>>()?;
+
+ for channel in channels {
+ let channel_id = channel.id.parse::()?;
+ if !archived_channels.contains(&channel_id) {
+ continue;
+ }
+ let overwrites = channel.permission_overwrites.as_deref().unwrap_or_default();
+ let reason = view_channel_reason(
+ guild_id,
+ user_id,
+ &user_roles,
+ &role_permissions,
+ &role_names,
+ overwrites,
+ &guild_name,
+ );
+ if let Some(reason) = reason
+ && can_view_channel(guild_id, bot_id, &bot_roles, &role_permissions, overwrites)
+ {
+ visible.push(ChannelAccess { channel_id, reason });
+ }
+ }
+ }
+
+ visible.sort_unstable_by_key(|access| access.channel_id);
+ Ok(visible)
+}
+
+async fn discord_get(
+ data: &WebData,
+ path: &str,
+) -> Result> {
+ let response = data
+ .http
+ .get(format!("https://discord.com/api/v10{}", path))
+ .header("Authorization", format!("Bot {}", data.bot_token))
+ .send()
+ .await?
+ .error_for_status()?;
+ Ok(response.json().await?)
+}
+
+async fn discord_get_optional(
+ data: &WebData,
+ path: &str,
+) -> Result
+
diff --git a/static/message.html b/static/message.html
index a04e443..5ea8c27 100644
--- a/static/message.html
+++ b/static/message.html
@@ -1,18 +1,19 @@
-Message $${{message_id}}
-$${{version_navigation}}
+Message {{message_id}}
+You can see this message because {{access_reason}}.
+{{version_navigation|safe}}
- - Author
- $${{author}} ($${{author_id}})
- - Server
- $${{server}}
- - Channel
- $${{channel}}
- - Timestamp
- $${{timestamp}}
+ - Author
- {{author}} ({{author_id}})
+ - Server
- {{server}}
+ - Channel
- {{channel}}
+ - Timestamp
- {{timestamp}}
Content
-$${{content}}
+{{content}}
Attachments
-$${{attachments}}
+{{attachments|safe}}
Embeds
-$${{embeds}}
+{{embeds|safe}}
diff --git a/static/page-button.html b/static/page-button.html
index a62e2ee..502a372 100644
--- a/static/page-button.html
+++ b/static/page-button.html
@@ -1,4 +1,5 @@
-
diff --git a/static/page.html b/static/page.html
index e9ece89..8450889 100644
--- a/static/page.html
+++ b/static/page.html
@@ -1,23 +1,40 @@
-
+
- $${{title}} — TG Archive
+ {{ title }} - TG Archive
+
-
- TG Archive
- {% endif %}
-
-$${{body}}
+
+{{ body|safe }}
+
diff --git a/static/pagination.html b/static/pagination.html
index e91a40a..895f21b 100644
--- a/static/pagination.html
+++ b/static/pagination.html
@@ -1,10 +1,10 @@
diff --git a/static/search-form.html b/static/search-form.html
index 3aee89f..67c5d91 100644
--- a/static/search-form.html
+++ b/static/search-form.html
@@ -1,4 +1,4 @@
-
diff --git a/static/server-icon.html b/static/server-icon.html
index fc1fd3c..2f6813c 100644
--- a/static/server-icon.html
+++ b/static/server-icon.html
@@ -1 +1 @@
- — icon
+ - icon
diff --git a/static/server-status.html b/static/server-status.html
index 66fdf4e..7d795b2 100644
--- a/static/server-status.html
+++ b/static/server-status.html
@@ -1,31 +1,31 @@
-$${{server_name}}
+{{server_name}}
- - Server ID
- $${{server_id}}
- - Messages
- $${{message_count}}
- - Archived users
- $${{user_count}}
- - Archived channels
- $${{channel_count}}
- - Message versions
- $${{version_count}}
- - Archived data
- $${{total_storage}}
- - Message content
- $${{message_storage}}
- - Attachments
- $${{attachment_storage}}
+ - Server ID
- {{server_id}}
+ - Messages
- {{message_count}}
+ - Archived users
- {{user_count}}
+ - Archived channels
- {{channel_count}}
+ - Message versions
- {{version_count}}
+ - Archived data
- {{total_storage}}
+ - Message content
- {{message_storage}}
+ - Attachments
- {{attachment_storage}}
-
Known names
-$${{names}}
+{{names|safe}}
Known icons
-$${{icons}}
+{{icons|safe}}
Channels
-$${{channels}}
+{{channels|safe}}
Observed users
Users are included after one of their messages is archived in this server.
-$${{users}}
+{{users|safe}}
diff --git a/static/server.html b/static/server.html
index 18cea28..9c50678 100644
--- a/static/server.html
+++ b/static/server.html
@@ -1 +1 @@
-$${{guild_name}} ($${{guild_id}})$${{icon}}
+{{guild_name}} ({{guild_id}}){{icon|safe}}
diff --git a/static/status-avatar.html b/static/status-avatar.html
index 4bb37de..800584f 100644
--- a/static/status-avatar.html
+++ b/static/status-avatar.html
@@ -1 +1 @@
-
— first seen $${{first_seen}}, last seen $${{last_seen}}
+
- first seen {{first_seen}}, last seen {{last_seen}}
diff --git a/static/status-channel.html b/static/status-channel.html
index e0f5120..a54fc92 100644
--- a/static/status-channel.html
+++ b/static/status-channel.html
@@ -1 +1 @@
-$${{channel_name}} — $${{message_count}} messages
+{{channel_name}} - {{message_count}} messages
diff --git a/static/status-icon.html b/static/status-icon.html
index 62a17f4..05e635a 100644
--- a/static/status-icon.html
+++ b/static/status-icon.html
@@ -1 +1 @@
-
— first seen $${{first_seen}}, last seen $${{last_seen}}
+
- first seen {{first_seen}}, last seen {{last_seen}}
diff --git a/static/status-name.html b/static/status-name.html
index 787de0f..2952045 100644
--- a/static/status-name.html
+++ b/static/status-name.html
@@ -1 +1 @@
-$${{name}} — first seen $${{first_seen}}, last seen $${{last_seen}}
+{{name}} - first seen {{first_seen}}, last seen {{last_seen}}
diff --git a/static/status-server.html b/static/status-server.html
index 057f81a..9baa8a7 100644
--- a/static/status-server.html
+++ b/static/status-server.html
@@ -1 +1 @@
-$${{server_name}} — $${{message_count}} messages
+{{server_name}} - {{message_count}} messages
diff --git a/static/status-user.html b/static/status-user.html
index 4ca86d7..004a85c 100644
--- a/static/status-user.html
+++ b/static/status-user.html
@@ -1 +1 @@
-$${{username}} — $${{message_count}} messages
+{{username}} - {{message_count}} messages
diff --git a/static/theme.css b/static/theme.css
new file mode 100644
index 0000000..bb20418
--- /dev/null
+++ b/static/theme.css
@@ -0,0 +1,209 @@
+:root {
+ color-scheme: light;
+ font-family: Arial, sans-serif;
+}
+
+html {
+ background: #f5f5f5;
+ color: #202020;
+}
+
+html.theme-black {
+ background: #181818;
+ color: #e6e6e6;
+ color-scheme: dark;
+}
+
+html.theme-oled {
+ background: #000;
+ color: #e6e6e6;
+ color-scheme: dark;
+}
+
+body {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ line-height: 1.45;
+ margin: 0 auto;
+ max-width: 1100px;
+ min-height: 100vh;
+ padding: 0 18px;
+}
+
+a {
+ color: #245b91;
+}
+
+a:hover {
+ color: #123f6b;
+}
+
+.theme-black a,
+.theme-oled a {
+ color: #8ebbea;
+}
+
+.theme-black a:hover,
+.theme-oled a:hover {
+ color: #b8d8f5;
+}
+
+.site-header,
+.site-footer {
+ padding: 18px 0;
+}
+
+.site-header {
+ align-items: center;
+ display: flex;
+ gap: 24px;
+ justify-content: space-between;
+}
+
+.site-brand {
+ font-size: 1.2rem;
+ white-space: nowrap;
+}
+
+.site-navigation {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.site-navigation form {
+ display: inline;
+}
+
+.page-content {
+ background: #fff;
+ border: 1px solid #d8d8d8;
+ flex: 1;
+ padding: 24px;
+}
+
+.theme-black .page-content {
+ background: #222;
+ border-color: #444;
+}
+
+.theme-oled .page-content {
+ background: #000;
+ border-color: #444;
+}
+
+button,
+input,
+select {
+ border: 1px solid #999;
+ border-radius: 3px;
+ box-sizing: border-box;
+ font: inherit;
+ padding: 7px;
+}
+
+button {
+ background: #f8f8f8;
+ color: #202020;
+ cursor: pointer;
+}
+
+button:hover {
+ background: #e9e9e9;
+}
+
+.theme-black button,
+.theme-black input,
+.theme-black select,
+.theme-oled button,
+.theme-oled input,
+.theme-oled select {
+ background: #292929;
+ border-color: #666;
+ color: #eee;
+}
+
+.theme-black button:hover,
+.theme-oled button:hover {
+ background: #3d3d3d;
+}
+
+form {
+ margin: 12px 0;
+}
+
+li {
+ margin: 8px 0;
+}
+
+dt {
+ font-weight: bold;
+ margin-top: 12px;
+}
+
+dd {
+ margin-left: 0;
+}
+
+pre {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.site-footer {
+ display: flex;
+ gap: 24px;
+ justify-content: space-between;
+}
+
+.site-footer > * {
+ margin: 0;
+}
+
+.footer-legal {
+ text-align: left;
+}
+
+.footer-theme {
+ text-align: right;
+}
+
+@media (max-width: 700px) {
+ body {
+ padding: 0 10px;
+ }
+
+ .site-header,
+ .site-footer {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 0;
+ }
+
+ .page-content {
+ padding: 16px;
+ }
+
+ .site-navigation {
+ line-height: 1.8;
+ }
+
+ .footer-theme {
+ text-align: left;
+ }
+
+ input,
+ select {
+ max-width: 100%;
+ }
+
+ img,
+ video,
+ iframe {
+ height: auto;
+ max-width: 100%;
+ }
+}
diff --git a/static/user-status.html b/static/user-status.html
index 8c62b50..dc1a7cb 100644
--- a/static/user-status.html
+++ b/static/user-status.html
@@ -1,30 +1,31 @@
-$${{username}}
+{{username}}
+You can see this user because they have messages in a channel where {{access_reason}}.
- - User ID
- $${{user_id}}
- - Messages
- $${{message_count}}
- - Servers
- $${{server_count}}
- - Channels
- $${{channel_count}}
- - Message versions
- $${{version_count}}
- - Archived data
- $${{total_storage}}
- - Message content
- $${{message_storage}}
- - Attachments
- $${{attachment_storage}}
+ - User ID
- {{user_id}}
+ - Messages
- {{message_count}}
+ - Servers
- {{server_count}}
+ - Channels
- {{channel_count}}
+ - Message versions
- {{version_count}}
+ - Archived data
- {{total_storage}}
+ - Message content
- {{message_storage}}
+ - Attachments
- {{attachment_storage}}
-
Known usernames
-$${{names}}
+{{names|safe}}
Known avatars
-$${{avatars}}
+{{avatars|safe}}
Servers
-$${{servers}}
+{{servers|safe}}
Channels
-$${{channels}}
+{{channels|safe}}
diff --git a/static/user.html b/static/user.html
index d863dea..0717339 100644
--- a/static/user.html
+++ b/static/user.html
@@ -1 +1 @@
-$${{discord_username}} ($${{discord_id}})
+{{discord_username}} ({{discord_id}})