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, Box> { + let response = data + .http + .get(format!("https://discord.com/api/v10{}", path)) + .header("Authorization", format!("Bot {}", data.bot_token)) + .send() + .await?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + Ok(Some(response.error_for_status()?.json().await?)) +} + +fn can_view_channel( + guild_id: i64, + user_id: i64, + member_roles: &[i64], + roles: &HashMap, + overwrites: &[DiscordOverwrite], +) -> bool { + const ADMINISTRATOR: u64 = 1 << 3; + const VIEW_CHANNEL: u64 = 1 << 10; + let mut permissions = roles.get(&guild_id).copied().unwrap_or_default(); + for role_id in member_roles { + permissions |= roles.get(role_id).copied().unwrap_or_default(); + } + if permissions & ADMINISTRATOR != 0 { + return true; + } + apply_overwrite(&mut permissions, overwrites, guild_id, 0); + let mut allow = 0; + let mut deny = 0; + for overwrite in overwrites { + let Ok(role_id) = overwrite.id.parse::() else { + continue; + }; + if overwrite.kind == 0 && member_roles.contains(&role_id) { + allow |= overwrite.allow.parse::().unwrap_or_default(); + deny |= overwrite.deny.parse::().unwrap_or_default(); + } + } + permissions &= !deny; + permissions |= allow; + apply_overwrite(&mut permissions, overwrites, user_id, 1); + permissions & VIEW_CHANNEL != 0 +} + +fn view_channel_reason( + guild_id: i64, + user_id: i64, + member_roles: &[i64], + roles: &HashMap, + role_names: &HashMap, + overwrites: &[DiscordOverwrite], + guild_name: &str, +) -> Option { + const ADMINISTRATOR: u64 = 1 << 3; + const VIEW_CHANNEL: u64 = 1 << 10; + if !can_view_channel(guild_id, user_id, member_roles, roles, overwrites) { + return None; + } + for role_id in member_roles { + if roles.get(role_id).copied().unwrap_or_default() & ADMINISTRATOR != 0 { + let role_name = role_names + .get(role_id) + .map(String::as_str) + .unwrap_or("unknown"); + return Some(format!( + "you have the {} administrator role in {}", + role_name, guild_name + )); + } + } + let member_allows_view = overwrites.iter().any(|overwrite| { + overwrite.kind == 1 + && overwrite.id.parse::() == Ok(user_id) + && overwrite.allow.parse::().unwrap_or_default() & VIEW_CHANNEL != 0 + }); + if member_allows_view { + return Some(format!( + "you have a channel-specific permission in {}", + guild_name + )); + } + for role_id in member_roles { + let role_allows_view = overwrites.iter().any(|overwrite| { + overwrite.kind == 0 + && overwrite.id.parse::() == Ok(*role_id) + && overwrite.allow.parse::().unwrap_or_default() & VIEW_CHANNEL != 0 + }); + if role_allows_view { + let role_name = role_names + .get(role_id) + .map(String::as_str) + .unwrap_or("unknown"); + return Some(format!("you have the {} role in {}", role_name, guild_name)); + } + } + let everyone_allows_view = overwrites.iter().any(|overwrite| { + overwrite.kind == 0 + && overwrite.id.parse::() == Ok(guild_id) + && overwrite.allow.parse::().unwrap_or_default() & VIEW_CHANNEL != 0 + }); + if everyone_allows_view { + return Some(format!("you are a member of {}", guild_name)); + } + for role_id in member_roles { + if roles.get(role_id).copied().unwrap_or_default() & VIEW_CHANNEL != 0 { + let role_name = role_names + .get(role_id) + .map(String::as_str) + .unwrap_or("unknown"); + return Some(format!("you have the {} role in {}", role_name, guild_name)); + } + } + Some(format!("you are a member of {}", guild_name)) +} + +fn apply_overwrite(permissions: &mut u64, overwrites: &[DiscordOverwrite], id: i64, kind: u8) { + let overwrite = overwrites + .iter() + .find(|overwrite| overwrite.kind == kind && overwrite.id.parse::() == Ok(id)); + if let Some(overwrite) = overwrite { + *permissions &= !overwrite.deny.parse::().unwrap_or_default(); + *permissions |= overwrite.allow.parse::().unwrap_or_default(); + } +} + async fn index(State(data): State) -> WebResult { + let stats = archive_stats(&data.pool).await.map_err(database_error)?; + let body = render_template(&IndexTemplate { + message_count: stats.messages, + user_count: stats.users, + server_count: stats.servers, + channel_count: stats.channels, + total_storage: format_bytes(stats.total_storage), + message_storage: format_bytes(stats.message_storage), + attachment_storage: format_bytes(stats.attachment_storage), + }); + Ok(Html(page("Archive", &body))) +} + +pub async fn archive_stats(pool: &PgPool) -> Result { let stats = sqlx::query_as::<_, (i64, i64, i64, i64, i64, i64, i64)>( "SELECT (SELECT COUNT(*) FROM messages), @@ -210,10 +1099,9 @@ async fn index(State(data): State) -> WebResult { + pg_total_relation_size('guild_users'), pg_total_relation_size('attachments');", ) - .fetch_one(&data.pool) - .await - .map_err(database_error)?; - let stats = ArchiveStats { + .fetch_one(pool) + .await?; + Ok(ArchiveStats { messages: stats.0, users: stats.1, servers: stats.2, @@ -221,33 +1109,36 @@ async fn index(State(data): State) -> WebResult { total_storage: stats.4, message_storage: stats.5, attachment_storage: stats.6, - }; - let body = INDEX_TEMPLATE - .replace("$${{message_count}}", &stats.messages.to_string()) - .replace("$${{user_count}}", &stats.users.to_string()) - .replace("$${{server_count}}", &stats.servers.to_string()) - .replace("$${{channel_count}}", &stats.channels.to_string()) - .replace("$${{total_storage}}", &format_bytes(stats.total_storage)) - .replace( - "$${{message_storage}}", - &format_bytes(stats.message_storage), - ) - .replace( - "$${{attachment_storage}}", - &format_bytes(stats.attachment_storage), - ); - Ok(Html(page("Archive", &body))) + }) } -async fn servers(State(data): State, Query(query): Query) -> WebResult { - render_servers(&data.pool, "", query.page.unwrap_or(1)).await +async fn servers( + State(data): State, + Extension(user): Extension, + Query(query): Query, +) -> WebResult { + render_servers(&data.pool, "", query.page.unwrap_or(1), &user.channel_ids).await } -async fn search_servers(State(data): State, Form(form): Form) -> WebResult { - render_servers(&data.pool, &form.search, form.page.unwrap_or(1)).await +async fn search_servers( + State(data): State, + Extension(user): Extension, + Form(form): Form, +) -> WebResult { + render_servers( + &data.pool, + &form.search, + form.page.unwrap_or(1), + &user.channel_ids, + ) + .await } -async fn server(State(data): State, Path(server_id): Path) -> WebResult { +async fn server( + State(data): State, + Extension(user): Extension, + Path(server_id): Path, +) -> WebResult { let server_name = sqlx::query_scalar::<_, String>("SELECT guild_name FROM guilds WHERE guild_id = $1;") .bind(server_id) @@ -303,11 +1194,12 @@ async fn server(State(data): State, Path(server_id): Path) -> WebR "SELECT c.channel_id, c.channel_name, COUNT(m.message_id) FROM channels c LEFT JOIN messages m ON m.channel_id = c.channel_id - WHERE c.guild_id = $1 + WHERE c.guild_id = $1 AND c.channel_id = ANY($2) GROUP BY c.channel_id, c.channel_name ORDER BY c.channel_name, c.channel_id;", ) .bind(server_id) + .bind(&user.channel_ids) .fetch_all(&data.pool) .await .map_err(database_error)?; @@ -315,35 +1207,38 @@ async fn server(State(data): State, Path(server_id): Path) -> WebR "SELECT u.discord_id, u.discord_username, COUNT(m.message_id) FROM guild_users gu JOIN discord_users u ON u.discord_id = gu.discord_id - LEFT JOIN messages m ON m.guild_id = gu.guild_id AND m.author_id = gu.discord_id - WHERE gu.guild_id = $1 + JOIN messages m ON m.guild_id = gu.guild_id AND m.author_id = gu.discord_id + WHERE gu.guild_id = $1 AND m.channel_id = ANY($2) GROUP BY u.discord_id, u.discord_username ORDER BY u.discord_username, u.discord_id;", ) .bind(server_id) + .bind(&user.channel_ids) .fetch_all(&data.pool) .await .map_err(database_error)?; - let body = SERVER_STATUS_TEMPLATE - .replace("$${{server_name}}", &escape_html(&server_name)) - .replace("$${{server_id}}", &server_id.to_string()) - .replace("$${{message_count}}", &stats.0.to_string()) - .replace("$${{user_count}}", &stats.1.to_string()) - .replace("$${{channel_count}}", &stats.2.to_string()) - .replace("$${{version_count}}", &stats.3.to_string()) - .replace("$${{total_storage}}", &format_bytes(stats.4 + stats.5)) - .replace("$${{message_storage}}", &format_bytes(stats.4)) - .replace("$${{attachment_storage}}", &format_bytes(stats.5)) - .replace("$${{names}}", &render_status_names(names)) - .replace("$${{icons}}", &render_status_icons(icons)) - .replace("$${{channels}}", &render_status_channels(channels)) - .replace("$${{users}}", &render_status_users(users)); + let body = render_template(&ServerStatusTemplate { + server_name: &server_name, + server_id, + message_count: stats.0, + user_count: stats.1, + channel_count: stats.2, + version_count: stats.3, + total_storage: format_bytes(stats.4 + stats.5), + message_storage: format_bytes(stats.4), + attachment_storage: format_bytes(stats.5), + names: render_status_names(names), + icons: render_status_icons(icons), + channels: render_status_channels(channels), + users: render_status_users(users), + }); Ok(Html(page(&server_name, &body))) } async fn server_messages( State(data): State, + Extension(user): Extension, Path(server_id): Path, Query(query): Query, ) -> WebResult { @@ -353,12 +1248,14 @@ async fn server_messages( "", "content", query.page.unwrap_or(1), + &user.channel_ids, ) .await } async fn search_server_messages( State(data): State, + Extension(user): Extension, Path(server_id): Path, Form(form): Form, ) -> WebResult { @@ -368,6 +1265,7 @@ async fn search_server_messages( &form.search, &form.search_by, form.page.unwrap_or(1), + &user.channel_ids, ) .await } @@ -378,6 +1276,7 @@ async fn render_server_messages( search: &str, search_by: &str, requested_page: i64, + channel_ids: &[i64], ) -> WebResult { let server_name = sqlx::query_scalar::<_, String>("SELECT guild_name FROM guilds WHERE guild_id = $1;") @@ -392,12 +1291,14 @@ async fn render_server_messages( search_by, requested_page, MessageScope::Server(server_id, server_name), + channel_ids, ) .await } async fn channel_messages( State(data): State, + Extension(user): Extension, Path(channel_id): Path, Query(query): Query, ) -> WebResult { @@ -407,12 +1308,14 @@ async fn channel_messages( "", "content", query.page.unwrap_or(1), + &user.channel_ids, ) .await } async fn search_channel_messages( State(data): State, + Extension(user): Extension, Path(channel_id): Path, Form(form): Form, ) -> WebResult { @@ -422,6 +1325,7 @@ async fn search_channel_messages( &form.search, &form.search_by, form.page.unwrap_or(1), + &user.channel_ids, ) .await } @@ -432,6 +1336,7 @@ async fn render_channel_messages( search: &str, search_by: &str, requested_page: i64, + channel_ids: &[i64], ) -> WebResult { let channel_name = sqlx::query_scalar::<_, String>("SELECT channel_name FROM channels WHERE channel_id = $1;") @@ -446,18 +1351,26 @@ async fn render_channel_messages( search_by, requested_page, MessageScope::Channel(channel_id, channel_name), + channel_ids, ) .await } -async fn render_servers(pool: &PgPool, search: &str, requested_page: i64) -> WebResult { +async fn render_servers( + pool: &PgPool, + search: &str, + requested_page: i64, + channel_ids: &[i64], +) -> WebResult { let search_pattern = format!("%{}%", search); let item_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM guilds - WHERE guild_name ILIKE $1 OR guild_id::text ILIKE $1;", + WHERE (guild_name ILIKE $1 OR guild_id::text ILIKE $1) + AND EXISTS(SELECT 1 FROM channels c WHERE c.guild_id = guilds.guild_id AND c.channel_id = ANY($2));", ) .bind(&search_pattern) + .bind(channel_ids) .fetch_one(pool) .await .map_err(database_error)?; @@ -465,11 +1378,13 @@ async fn render_servers(pool: &PgPool, search: &str, requested_page: i64) -> Web let servers = sqlx::query_as::<_, (i64, String, Option)>( "SELECT guild_id, guild_name, guild_icon_url FROM guilds - WHERE guild_name ILIKE $1 OR guild_id::text ILIKE $1 + WHERE (guild_name ILIKE $1 OR guild_id::text ILIKE $1) + AND EXISTS(SELECT 1 FROM channels c WHERE c.guild_id = guilds.guild_id AND c.channel_id = ANY($2)) ORDER BY guild_name, guild_id - LIMIT $2 OFFSET $3;", + LIMIT $3 OFFSET $4;", ) .bind(&search_pattern) + .bind(channel_ids) .bind(ITEMS_PER_PAGE) .bind(pagination.offset()) .fetch_all(pool) @@ -479,38 +1394,57 @@ async fn render_servers(pool: &PgPool, search: &str, requested_page: i64) -> Web let mut items = String::new(); for (guild_id, guild_name, guild_icon_url) in servers { let icon = guild_icon_url - .map(|icon_url| SERVER_ICON_TEMPLATE.replace("$${{icon_url}}", &escape_html(&icon_url))) + .map(|icon_url| { + render_template(&ServerIconTemplate { + icon_url: &icon_url, + }) + }) .unwrap_or_default(); - items.push_str( - &SERVER_TEMPLATE - .replace("$${{guild_name}}", &escape_html(&guild_name)) - .replace("$${{guild_id}}", &guild_id.to_string()) - .replace("$${{icon}}", &icon), - ); + items.push_str(&render_template(&ServerTemplate { + guild_id, + guild_name: &guild_name, + icon, + })); } - let body = LIST_TEMPLATE - .replace("$${{title}}", "Servers") - .replace( - "$${{search_form}}", - &search_form("/servers", search, "Search servers"), - ) - .replace("$${{item_count}}", &item_count.to_string()) - .replace("$${{item_name}}", "archived servers") - .replace("$${{items}}", &items) - .replace("$${{pagination}}", &pagination.render("/servers", search)); + let body = render_template(&ListTemplate { + title: "Servers", + search_form: search_form("/servers", search, "Search servers"), + item_count, + item_name: "archived servers", + items, + pagination: pagination.render("/servers", search), + }); Ok(Html(page("Servers", &body))) } -async fn channels(State(data): State, Query(query): Query) -> WebResult { - render_channels(&data.pool, "", query.page.unwrap_or(1)).await +async fn channels( + State(data): State, + Extension(user): Extension, + Query(query): Query, +) -> WebResult { + render_channels(&data.pool, "", query.page.unwrap_or(1), &user.channel_ids).await } -async fn search_channels(State(data): State, Form(form): Form) -> WebResult { - render_channels(&data.pool, &form.search, form.page.unwrap_or(1)).await +async fn search_channels( + State(data): State, + Extension(user): Extension, + Form(form): Form, +) -> WebResult { + render_channels( + &data.pool, + &form.search, + form.page.unwrap_or(1), + &user.channel_ids, + ) + .await } -async fn channel(State(data): State, Path(channel_id): Path) -> WebResult { +async fn channel( + State(data): State, + Extension(user): Extension, + Path(channel_id): Path, +) -> WebResult { let channel = sqlx::query_as::<_, (String, i64, String)>( "SELECT c.channel_name, g.guild_id, g.guild_name FROM channels c @@ -566,33 +1500,42 @@ async fn channel(State(data): State, Path(channel_id): Path) -> We .await .map_err(database_error)?; - let body = CHANNEL_STATUS_TEMPLATE - .replace("$${{channel_name}}", &escape_html(&channel.0)) - .replace("$${{channel_id}}", &channel_id.to_string()) - .replace("$${{server_id}}", &channel.1.to_string()) - .replace("$${{server_name}}", &escape_html(&channel.2)) - .replace("$${{message_count}}", &stats.0.to_string()) - .replace("$${{user_count}}", &stats.1.to_string()) - .replace("$${{version_count}}", &stats.2.to_string()) - .replace("$${{total_storage}}", &format_bytes(stats.3 + stats.4)) - .replace("$${{message_storage}}", &format_bytes(stats.3)) - .replace("$${{attachment_storage}}", &format_bytes(stats.4)) - .replace("$${{names}}", &render_status_names(names)) - .replace("$${{users}}", &render_status_users(users)); + let body = render_template(&ChannelStatusTemplate { + channel_name: &channel.0, + channel_id, + server_id: channel.1, + server_name: &channel.2, + message_count: stats.0, + user_count: stats.1, + version_count: stats.2, + total_storage: format_bytes(stats.3 + stats.4), + message_storage: format_bytes(stats.3), + attachment_storage: format_bytes(stats.4), + names: render_status_names(names), + users: render_status_users(users), + access_reason: user.access_reason(channel_id), + }); Ok(Html(page(&channel.0, &body))) } -async fn render_channels(pool: &PgPool, search: &str, requested_page: i64) -> WebResult { +async fn render_channels( + pool: &PgPool, + search: &str, + requested_page: i64, + channel_ids: &[i64], +) -> WebResult { let search_pattern = format!("%{}%", search); let item_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM channels c JOIN guilds g ON g.guild_id = c.guild_id - WHERE c.channel_name ILIKE $1 + WHERE (c.channel_name ILIKE $1 OR c.channel_id::text ILIKE $1 - OR g.guild_name ILIKE $1;", + OR g.guild_name ILIKE $1) + AND c.channel_id = ANY($2);", ) .bind(&search_pattern) + .bind(channel_ids) .fetch_one(pool) .await .map_err(database_error)?; @@ -601,13 +1544,15 @@ async fn render_channels(pool: &PgPool, search: &str, requested_page: i64) -> We "SELECT c.channel_id, c.channel_name, g.guild_id, g.guild_name FROM channels c JOIN guilds g ON g.guild_id = c.guild_id - WHERE c.channel_name ILIKE $1 + WHERE (c.channel_name ILIKE $1 OR c.channel_id::text ILIKE $1 - OR g.guild_name ILIKE $1 + OR g.guild_name ILIKE $1) + AND c.channel_id = ANY($2) ORDER BY c.channel_name, c.channel_id - LIMIT $2 OFFSET $3;", + LIMIT $3 OFFSET $4;", ) .bind(&search_pattern) + .bind(channel_ids) .bind(ITEMS_PER_PAGE) .bind(pagination.offset()) .fetch_all(pool) @@ -616,37 +1561,52 @@ async fn render_channels(pool: &PgPool, search: &str, requested_page: i64) -> We let mut items = String::new(); for (channel_id, channel_name, server_id, server_name) in channels { - items.push_str( - &CHANNEL_TEMPLATE - .replace("$${{channel_id}}", &channel_id.to_string()) - .replace("$${{channel_name}}", &escape_html(&channel_name)) - .replace("$${{server_id}}", &server_id.to_string()) - .replace("$${{server_name}}", &escape_html(&server_name)), - ); + items.push_str(&render_template(&ChannelTemplate { + channel_id, + channel_name: &channel_name, + server_id, + server_name: &server_name, + })); } - let body = LIST_TEMPLATE - .replace("$${{title}}", "Channels") - .replace( - "$${{search_form}}", - &search_form("/channels", search, "Search channels"), - ) - .replace("$${{item_count}}", &item_count.to_string()) - .replace("$${{item_name}}", "archived channels") - .replace("$${{items}}", &items) - .replace("$${{pagination}}", &pagination.render("/channels", search)); + let body = render_template(&ListTemplate { + title: "Channels", + search_form: search_form("/channels", search, "Search channels"), + item_count, + item_name: "archived channels", + items, + pagination: pagination.render("/channels", search), + }); Ok(Html(page("Channels", &body))) } -async fn users(State(data): State, Query(query): Query) -> WebResult { - render_users(&data.pool, "", query.page.unwrap_or(1)).await +async fn users( + State(data): State, + Extension(user): Extension, + Query(query): Query, +) -> WebResult { + render_users(&data.pool, "", query.page.unwrap_or(1), &user.channel_ids).await } -async fn search_users(State(data): State, Form(form): Form) -> WebResult { - render_users(&data.pool, &form.search, form.page.unwrap_or(1)).await +async fn search_users( + State(data): State, + Extension(user): Extension, + Form(form): Form, +) -> WebResult { + render_users( + &data.pool, + &form.search, + form.page.unwrap_or(1), + &user.channel_ids, + ) + .await } -async fn user(State(data): State, Path(user_id): Path) -> WebResult { +async fn user( + State(data): State, + Extension(web_user): Extension, + Path(user_id): Path, +) -> WebResult { let username = sqlx::query_scalar::<_, String>( "SELECT discord_username FROM discord_users WHERE discord_id = $1;", ) @@ -703,12 +1663,13 @@ async fn user(State(data): State, Path(user_id): Path) -> WebResul "SELECT g.guild_id, g.guild_name, COUNT(m.message_id) FROM guild_users gu JOIN guilds g ON g.guild_id = gu.guild_id - LEFT JOIN messages m ON m.guild_id = gu.guild_id AND m.author_id = gu.discord_id - WHERE gu.discord_id = $1 + JOIN messages m ON m.guild_id = gu.guild_id AND m.author_id = gu.discord_id + WHERE gu.discord_id = $1 AND m.channel_id = ANY($2) GROUP BY g.guild_id, g.guild_name ORDER BY g.guild_name, g.guild_id;", ) .bind(user_id) + .bind(&web_user.channel_ids) .fetch_all(&data.pool) .await .map_err(database_error)?; @@ -716,42 +1677,59 @@ async fn user(State(data): State, Path(user_id): Path) -> WebResul "SELECT c.channel_id, c.channel_name, COUNT(m.message_id) FROM messages m JOIN channels c ON c.channel_id = m.channel_id - WHERE m.author_id = $1 + WHERE m.author_id = $1 AND m.channel_id = ANY($2) GROUP BY c.channel_id, c.channel_name ORDER BY c.channel_name, c.channel_id;", ) .bind(user_id) + .bind(&web_user.channel_ids) .fetch_all(&data.pool) .await .map_err(database_error)?; + let access_reason = channels + .first() + .map(|channel| web_user.access_reason(channel.0)) + .unwrap_or("you can see archived messages from them"); - let body = USER_STATUS_TEMPLATE - .replace("$${{username}}", &escape_html(&username)) - .replace("$${{user_id}}", &user_id.to_string()) - .replace("$${{message_count}}", &stats.0.to_string()) - .replace("$${{server_count}}", &stats.1.to_string()) - .replace("$${{channel_count}}", &stats.2.to_string()) - .replace("$${{version_count}}", &stats.3.to_string()) - .replace("$${{total_storage}}", &format_bytes(stats.4 + stats.5)) - .replace("$${{message_storage}}", &format_bytes(stats.4)) - .replace("$${{attachment_storage}}", &format_bytes(stats.5)) - .replace("$${{names}}", &render_status_names(names)) - .replace("$${{avatars}}", &render_status_avatars(avatars)) - .replace("$${{servers}}", &render_status_servers(servers)) - .replace("$${{channels}}", &render_status_channels(channels)); + let body = render_template(&UserStatusTemplate { + username: &username, + user_id, + message_count: stats.0, + server_count: stats.1, + channel_count: stats.2, + version_count: stats.3, + total_storage: format_bytes(stats.4 + stats.5), + message_storage: format_bytes(stats.4), + attachment_storage: format_bytes(stats.5), + names: render_status_names(names), + avatars: render_status_avatars(avatars), + servers: render_status_servers(servers), + channels: render_status_channels(channels), + access_reason, + }); Ok(Html(page(&username, &body))) } async fn user_messages( State(data): State, + Extension(user): Extension, Path(user_id): Path, Query(query): Query, ) -> WebResult { - render_user_messages(&data.pool, user_id, "", "content", query.page.unwrap_or(1)).await + render_user_messages( + &data.pool, + user_id, + "", + "content", + query.page.unwrap_or(1), + &user.channel_ids, + ) + .await } async fn search_user_messages( State(data): State, + Extension(user): Extension, Path(user_id): Path, Form(form): Form, ) -> WebResult { @@ -761,6 +1739,7 @@ async fn search_user_messages( &form.search, &form.search_by, form.page.unwrap_or(1), + &user.channel_ids, ) .await } @@ -771,6 +1750,7 @@ async fn render_user_messages( search: &str, search_by: &str, requested_page: i64, + channel_ids: &[i64], ) -> WebResult { let username = sqlx::query_scalar::<_, String>( "SELECT discord_username FROM discord_users WHERE discord_id = $1;", @@ -786,18 +1766,26 @@ async fn render_user_messages( search_by, requested_page, MessageScope::User(user_id, username), + channel_ids, ) .await } -async fn render_users(pool: &PgPool, search: &str, requested_page: i64) -> WebResult { +async fn render_users( + pool: &PgPool, + search: &str, + requested_page: i64, + channel_ids: &[i64], +) -> WebResult { let search_pattern = format!("%{}%", search); let item_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM discord_users - WHERE discord_username ILIKE $1 OR discord_id::text ILIKE $1;", + WHERE (discord_username ILIKE $1 OR discord_id::text ILIKE $1) + AND EXISTS(SELECT 1 FROM messages m WHERE m.author_id = discord_users.discord_id AND m.channel_id = ANY($2));", ) .bind(&search_pattern) + .bind(channel_ids) .fetch_one(pool) .await .map_err(database_error)?; @@ -805,11 +1793,13 @@ async fn render_users(pool: &PgPool, search: &str, requested_page: i64) -> WebRe let users = sqlx::query_as::<_, (i64, String)>( "SELECT discord_id, discord_username FROM discord_users - WHERE discord_username ILIKE $1 OR discord_id::text ILIKE $1 + WHERE (discord_username ILIKE $1 OR discord_id::text ILIKE $1) + AND EXISTS(SELECT 1 FROM messages m WHERE m.author_id = discord_users.discord_id AND m.channel_id = ANY($2)) ORDER BY discord_username, discord_id - LIMIT $2 OFFSET $3;", + LIMIT $3 OFFSET $4;", ) .bind(&search_pattern) + .bind(channel_ids) .bind(ITEMS_PER_PAGE) .bind(pagination.offset()) .fetch_all(pool) @@ -818,39 +1808,42 @@ async fn render_users(pool: &PgPool, search: &str, requested_page: i64) -> WebRe let mut items = String::new(); for (discord_id, discord_username) in users { - items.push_str( - &USER_TEMPLATE - .replace("$${{discord_username}}", &escape_html(&discord_username)) - .replace("$${{discord_id}}", &discord_id.to_string()), - ); + items.push_str(&render_template(&UserTemplate { + discord_id, + discord_username: &discord_username, + })); } - let body = LIST_TEMPLATE - .replace("$${{title}}", "Users") - .replace( - "$${{search_form}}", - &search_form("/users", search, "Search users"), - ) - .replace("$${{item_count}}", &item_count.to_string()) - .replace("$${{item_name}}", "archived users") - .replace("$${{items}}", &items) - .replace("$${{pagination}}", &pagination.render("/users", search)); + let body = render_template(&ListTemplate { + title: "Users", + search_form: search_form("/users", search, "Search users"), + item_count, + item_name: "archived users", + items, + pagination: pagination.render("/users", search), + }); Ok(Html(page("Users", &body))) } -async fn messages(State(data): State, Query(query): Query) -> WebResult { +async fn messages( + State(data): State, + Extension(user): Extension, + Query(query): Query, +) -> WebResult { render_messages( &data.pool, "", "content", query.page.unwrap_or(1), MessageScope::All, + &user.channel_ids, ) .await } async fn search_messages( State(data): State, + Extension(user): Extension, Form(form): Form, ) -> WebResult { render_messages( @@ -859,6 +1852,7 @@ async fn search_messages( &form.search_by, form.page.unwrap_or(1), MessageScope::All, + &user.channel_ids, ) .await } @@ -869,6 +1863,7 @@ async fn render_messages( search_by: &str, requested_page: i64, scope: MessageScope, + channel_ids: &[i64], ) -> WebResult { let search_by = if search_by == "timestamp" { "timestamp" @@ -887,13 +1882,15 @@ async fn render_messages( END AND ($3::bigint IS NULL OR m.guild_id = $3) AND ($4::bigint IS NULL OR m.author_id = $4) - AND ($5::bigint IS NULL OR m.channel_id = $5);", + AND ($5::bigint IS NULL OR m.channel_id = $5) + AND m.channel_id = ANY($6);", ) .bind(&search_pattern) .bind(search_by) .bind(scope.server_id()) .bind(scope.user_id()) .bind(scope.channel_id()) + .bind(channel_ids) .fetch_one(pool) .await .map_err(database_error)?; @@ -931,14 +1928,16 @@ async fn render_messages( AND ($3::bigint IS NULL OR m.guild_id = $3) AND ($4::bigint IS NULL OR m.author_id = $4) AND ($5::bigint IS NULL OR m.channel_id = $5) + AND m.channel_id = ANY($6) ORDER BY m.timestamp DESC, m.message_id DESC - LIMIT $6 OFFSET $7;", + LIMIT $7 OFFSET $8;", ) .bind(&search_pattern) .bind(search_by) .bind(scope.server_id()) .bind(scope.user_id()) .bind(scope.channel_id()) + .bind(channel_ids) .bind(ITEMS_PER_PAGE) .bind(pagination.offset()) .fetch_all(pool) @@ -959,40 +1958,35 @@ async fn render_messages( embed_count, ) in messages { - items.push_str( - &MESSAGE_LIST_ITEM_TEMPLATE - .replace("$${{message_id}}", &message_id.to_string()) - .replace("$${{author_id}}", &author_id.to_string()) - .replace("$${{author}}", &escape_html(&author)) - .replace("$${{server_id}}", &server_id.to_string()) - .replace("$${{server}}", &escape_html(&server)) - .replace("$${{channel_id}}", &channel_id.to_string()) - .replace("$${{channel}}", &escape_html(&channel)) - .replace("$${{timestamp}}", &escape_html(×tamp)) - .replace("$${{attachment_count}}", &attachment_count.to_string()) - .replace("$${{embed_count}}", &embed_count.to_string()), - ); + items.push_str(&render_template(&MessageListItemTemplate { + message_id, + author_id, + author: &author, + server_id, + server: &server, + channel_id, + channel: &channel, + timestamp: ×tamp, + attachment_count, + embed_count, + })); } let title = scope.title(); let action = scope.action(); - let body = MESSAGE_LIST_TEMPLATE - .replace("$${{title}}", &escape_html(&title)) - .replace( - "$${{search_form}}", - &message_search_form(&action, search, search_by), - ) - .replace("$${{item_count}}", &item_count.to_string()) - .replace("$${{items}}", &items) - .replace( - "$${{pagination}}", - &pagination.render_messages(&action, search, search_by), - ); + let body = render_template(&MessageListTemplate { + title: &title, + search_form: message_search_form(&action, search, search_by), + item_count, + items, + pagination: pagination.render_messages(&action, search, search_by), + }); Ok(Html(page(&title, &body))) } async fn message( State(data): State, + Extension(user): Extension, Path(message_id): Path, Query(query): Query, ) -> WebResult { @@ -1066,63 +2060,45 @@ async fn message( let mut rendered_attachments = String::new(); for (attachment_id, filename, description, content_type, size) in attachments { - let rendered_content_type = content_type - .map(|value| ATTACHMENT_VALUE_TEMPLATE.replace("$${{value}}", &escape_html(&value))) - .unwrap_or_default(); - let rendered_description = description - .map(|value| ATTACHMENT_VALUE_TEMPLATE.replace("$${{value}}", &escape_html(&value))) - .unwrap_or_default(); - rendered_attachments.push_str( - &ATTACHMENT_TEMPLATE - .replace("$${{attachment_id}}", &attachment_id.to_string()) - .replace("$${{message_version}}", &message_version.to_string()) - .replace("$${{filename}}", &escape_html(&filename)) - .replace("$${{size}}", &size.to_string()) - .replace("$${{content_type}}", &rendered_content_type) - .replace("$${{description}}", &rendered_description), - ); + rendered_attachments.push_str(&render_template(&AttachmentTemplate { + attachment_id, + message_version, + filename: &filename, + size, + content_type: content_type.as_deref(), + description: description.as_deref(), + })); } let mut rendered_embeds = String::new(); for (_embed_index, title, description, url) in embeds { - let rendered_title = title - .map(|value| EMBED_TITLE_TEMPLATE.replace("$${{title}}", &escape_html(&value))) - .unwrap_or_default(); - let rendered_description = description - .map(|value| { - EMBED_DESCRIPTION_TEMPLATE.replace("$${{description}}", &escape_html(&value)) - }) - .unwrap_or_default(); - let rendered_url = url - .map(|value| EMBED_URL_TEMPLATE.replace("$${{url}}", &escape_html(&value))) - .unwrap_or_default(); - rendered_embeds.push_str( - &EMBED_TEMPLATE - .replace("$${{title}}", &rendered_title) - .replace("$${{description}}", &rendered_description) - .replace("$${{url}}", &rendered_url), - ); + rendered_embeds.push_str(&render_template(&EmbedTemplate { + title: title.as_deref(), + description: description.as_deref(), + url: url.as_deref(), + })); } - let body = MESSAGE_TEMPLATE - .replace("$${{message_id}}", &message_id.to_string()) - .replace("$${{author}}", &escape_html(&author)) - .replace("$${{author_id}}", &author_id.to_string()) - .replace("$${{server_id}}", &server_id.to_string()) - .replace("$${{server}}", &escape_html(&server)) - .replace("$${{channel_id}}", &channel_id.to_string()) - .replace("$${{channel}}", &escape_html(&channel)) - .replace("$${{timestamp}}", &escape_html(×tamp)) - .replace( - "$${{version_navigation}}", - &message_version_navigation(message_id, message_version, version_count, &archived_at), - ) - .replace( - "$${{content}}", - &escape_html(content.as_deref().unwrap_or("")), - ) - .replace("$${{attachments}}", &rendered_attachments) - .replace("$${{embeds}}", &rendered_embeds); + let body = render_template(&MessageTemplate { + message_id, + author: &author, + author_id, + server_id, + server: &server, + channel_id, + channel: &channel, + timestamp: ×tamp, + version_navigation: message_version_navigation( + message_id, + message_version, + version_count, + &archived_at, + ), + content: content.as_deref().unwrap_or(""), + attachments: rendered_attachments, + embeds: rendered_embeds, + access_reason: user.access_reason(channel_id), + }); Ok(Html(page(&format!("Message {}", message_id), &body))) } @@ -1196,79 +2172,74 @@ impl Pagination { } fn render(&self, action: &str, search: &str) -> String { - self.render_with_fields(action, search, "") + self.render_with_fields(action, search, None) } fn render_messages(&self, action: &str, search: &str, search_by: &str) -> String { - self.render_with_fields( - action, - search, - &format!( - " \n", - escape_html(search_by) - ), - ) + self.render_with_fields(action, search, Some(search_by)) } - fn render_with_fields(&self, action: &str, search: &str, additional_fields: &str) -> String { + fn render_with_fields(&self, action: &str, search: &str, search_by: Option<&str>) -> String { let mut first_pages = String::new(); if self.current_page > 1 { first_pages.push_str(&page_button( action, search, - additional_fields, + search_by, self.current_page - 1, - "<", + "<", )); } for page_number in 1..=self.total_pages.min(SHOWN_PAGES) { - first_pages.push_str(&self.number(action, search, additional_fields, page_number)); + first_pages.push_str(&self.number(action, search, search_by, page_number)); } let mut arbitrary_page = String::new(); let mut final_pages = String::new(); if self.total_pages > SHOWN_PAGES { - arbitrary_page = ARBITRARY_PAGE_TEMPLATE - .replace("$${{action}}", action) - .replace("$${{search}}", &escape_html(search)) - .replace("$${{additional_fields}}", additional_fields) - .replace("$${{total_pages}}", &self.total_pages.to_string()); + arbitrary_page = render_template(&ArbitraryPageTemplate { + action, + search, + search_by, + total_pages: self.total_pages, + }); for page_number in (self.total_pages - 2).max(SHOWN_PAGES + 1)..=self.total_pages { - final_pages.push_str(&self.number(action, search, additional_fields, page_number)); + final_pages.push_str(&self.number(action, search, search_by, page_number)); } } if self.current_page < self.total_pages { final_pages.push_str(&page_button( action, search, - additional_fields, + search_by, self.current_page + 1, - ">", + ">", )); } - PAGINATION_TEMPLATE - .replace("$${{first_pages}}", &first_pages) - .replace("$${{arbitrary_page}}", &arbitrary_page) - .replace("$${{final_pages}}", &final_pages) - .replace("$${{current_page}}", &self.current_page.to_string()) - .replace("$${{total_pages}}", &self.total_pages.to_string()) + render_template(&PaginationTemplate { + first_pages, + arbitrary_page, + final_pages, + current_page: self.current_page, + total_pages: self.total_pages, + }) } fn number( &self, action: &str, search: &str, - additional_fields: &str, + search_by: Option<&str>, page_number: i64, ) -> String { if page_number == self.current_page { - CURRENT_PAGE_TEMPLATE.replace("$${{page_number}}", &page_number.to_string()) + render_template(&CurrentPageTemplate { page_number }) } else { page_button( action, search, - additional_fields, + search_by, page_number, &page_number.to_string(), ) @@ -1279,34 +2250,35 @@ impl Pagination { fn page_button( action: &str, search: &str, - additional_fields: &str, + search_by: Option<&str>, page_number: i64, label: &str, ) -> String { - PAGE_BUTTON_TEMPLATE - .replace("$${{action}}", action) - .replace("$${{search}}", &escape_html(search)) - .replace("$${{additional_fields}}", additional_fields) - .replace("$${{page_number}}", &page_number.to_string()) - .replace("$${{label}}", label) + render_template(&PageButtonTemplate { + action, + search, + search_by, + page_number, + label, + }) } fn search_form(action: &str, search: &str, label: &str) -> String { - SEARCH_FORM_TEMPLATE - .replace("$${{action}}", action) - .replace("$${{label}}", label) - .replace("$${{search}}", &escape_html(search)) + render_template(&SearchFormTemplate { + action, + label, + search, + }) } fn render_status_names(names: Vec<(String, String, String)>) -> String { let mut rendered_names = String::new(); for (name, first_seen, last_seen) in names { - rendered_names.push_str( - &STATUS_NAME_TEMPLATE - .replace("$${{name}}", &escape_html(&name)) - .replace("$${{first_seen}}", &escape_html(&first_seen)) - .replace("$${{last_seen}}", &escape_html(&last_seen)), - ); + rendered_names.push_str(&render_template(&StatusNameTemplate { + name: &name, + first_seen: &first_seen, + last_seen: &last_seen, + })); } rendered_names } @@ -1314,12 +2286,11 @@ fn render_status_names(names: Vec<(String, String, String)>) -> String { fn render_status_icons(icons: Vec<(String, String, String)>) -> String { let mut rendered_icons = String::new(); for (icon_url, first_seen, last_seen) in icons { - rendered_icons.push_str( - &STATUS_ICON_TEMPLATE - .replace("$${{icon_url}}", &escape_html(&icon_url)) - .replace("$${{first_seen}}", &escape_html(&first_seen)) - .replace("$${{last_seen}}", &escape_html(&last_seen)), - ); + rendered_icons.push_str(&render_template(&StatusIconTemplate { + icon_url: &icon_url, + first_seen: &first_seen, + last_seen: &last_seen, + })); } rendered_icons } @@ -1327,12 +2298,11 @@ fn render_status_icons(icons: Vec<(String, String, String)>) -> String { fn render_status_avatars(avatars: Vec<(String, String, String)>) -> String { let mut rendered_avatars = String::new(); for (avatar_url, first_seen, last_seen) in avatars { - rendered_avatars.push_str( - &STATUS_AVATAR_TEMPLATE - .replace("$${{avatar_url}}", &escape_html(&avatar_url)) - .replace("$${{first_seen}}", &escape_html(&first_seen)) - .replace("$${{last_seen}}", &escape_html(&last_seen)), - ); + rendered_avatars.push_str(&render_template(&StatusAvatarTemplate { + avatar_url: &avatar_url, + first_seen: &first_seen, + last_seen: &last_seen, + })); } rendered_avatars } @@ -1340,12 +2310,11 @@ fn render_status_avatars(avatars: Vec<(String, String, String)>) -> String { fn render_status_channels(channels: Vec<(i64, String, i64)>) -> String { let mut rendered_channels = String::new(); for (channel_id, channel_name, message_count) in channels { - rendered_channels.push_str( - &STATUS_CHANNEL_TEMPLATE - .replace("$${{channel_id}}", &channel_id.to_string()) - .replace("$${{channel_name}}", &escape_html(&channel_name)) - .replace("$${{message_count}}", &message_count.to_string()), - ); + rendered_channels.push_str(&render_template(&StatusChannelTemplate { + channel_id, + channel_name: &channel_name, + message_count, + })); } rendered_channels } @@ -1353,12 +2322,11 @@ fn render_status_channels(channels: Vec<(i64, String, i64)>) -> String { fn render_status_servers(servers: Vec<(i64, String, i64)>) -> String { let mut rendered_servers = String::new(); for (server_id, server_name, message_count) in servers { - rendered_servers.push_str( - &STATUS_SERVER_TEMPLATE - .replace("$${{server_id}}", &server_id.to_string()) - .replace("$${{server_name}}", &escape_html(&server_name)) - .replace("$${{message_count}}", &message_count.to_string()), - ); + rendered_servers.push_str(&render_template(&StatusServerTemplate { + server_id, + server_name: &server_name, + message_count, + })); } rendered_servers } @@ -1366,12 +2334,11 @@ fn render_status_servers(servers: Vec<(i64, String, i64)>) -> String { fn render_status_users(users: Vec<(i64, String, i64)>) -> String { let mut rendered_users = String::new(); for (user_id, username, message_count) in users { - rendered_users.push_str( - &STATUS_USER_TEMPLATE - .replace("$${{user_id}}", &user_id.to_string()) - .replace("$${{username}}", &escape_html(&username)) - .replace("$${{message_count}}", &message_count.to_string()), - ); + rendered_users.push_str(&render_template(&StatusUserTemplate { + user_id, + username: &username, + message_count, + })); } rendered_users } @@ -1382,11 +2349,12 @@ fn message_search_form(action: &str, search: &str, search_by: &str) -> String { } else { (" selected", "") }; - MESSAGE_SEARCH_FORM_TEMPLATE - .replace("$${{action}}", action) - .replace("$${{search}}", &escape_html(search)) - .replace("$${{content_selected}}", content_selected) - .replace("$${{timestamp_selected}}", timestamp_selected) + render_template(&MessageSearchFormTemplate { + action, + search, + content_selected, + timestamp_selected, + }) } fn message_version_navigation( @@ -1405,35 +2373,42 @@ fn message_version_navigation( } else { String::new() }; - MESSAGE_VERSION_NAVIGATION_TEMPLATE - .replace("$${{message_id}}", &message_id.to_string()) - .replace("$${{current_version}}", ¤t_version.to_string()) - .replace("$${{version_count}}", &version_count.to_string()) - .replace("$${{archived_at}}", &escape_html(archived_at)) - .replace("$${{previous_version}}", &previous_version) - .replace("$${{next_version}}", &next_version) + render_template(&MessageVersionNavigationTemplate { + message_id, + current_version, + version_count, + archived_at, + previous_version, + next_version, + }) } fn message_version_button(message_id: i64, version: i64, label: &str) -> String { - MESSAGE_VERSION_BUTTON_TEMPLATE - .replace("$${{message_id}}", &message_id.to_string()) - .replace("$${{version}}", &version.to_string()) - .replace("$${{label}}", label) + render_template(&MessageVersionButtonTemplate { + message_id, + version, + label, + }) } fn page(title: &str, body: &str) -> String { - PAGE_TEMPLATE - .replace("$${{title}}", &escape_html(title)) - .replace("$${{body}}", body) + render_page(title, body, true) } -fn escape_html(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") +fn render_page(title: &str, body: &str, logged_in: bool) -> String { + let theme = ACTIVE_THEME + .try_with(|theme| theme.as_str()) + .unwrap_or("white"); + render_template(&PageTemplate { + title, + body, + theme, + logged_in, + }) +} + +fn render_template(template: &impl Template) -> String { + template.render().unwrap() } fn safe_filename(filename: &str) -> String { @@ -1465,7 +2440,7 @@ fn is_inline_content_type(content_type: &str) -> bool { || content_type.starts_with("video/") } -fn format_bytes(bytes: i64) -> String { +pub fn format_bytes(bytes: i64) -> String { const UNITS: [&str; 5] = ["bytes", "KB", "MB", "GB", "TB"]; let mut size = bytes.max(0) as f64; @@ -1488,7 +2463,9 @@ fn database_error(error: sqlx::Error) -> (StatusCode, Html) { StatusCode::INTERNAL_SERVER_ERROR, Html(page( "Error", - &ERROR_TEMPLATE.replace("$${{message}}", "The archive could not be loaded."), + &render_template(&ErrorTemplate { + message: "The archive could not be loaded.", + }), )), ) } @@ -1498,7 +2475,7 @@ fn not_found(message: &str) -> (StatusCode, Html) { StatusCode::NOT_FOUND, Html(page( "Not found", - &ERROR_TEMPLATE.replace("$${{message}}", message), + &render_template(&ErrorTemplate { message }), )), ) } @@ -1509,10 +2486,15 @@ mod tests { #[test] fn escapes_html_from_the_database() { - assert_eq!( - escape_html(""), - "<script>'"&</script>" - ); + let html = render_template(&StatusNameTemplate { + name: "", + first_seen: "", + last_seen: "", + }); + + assert!(html.contains("<script>'"&</script>")); + assert!(html.contains("<old>")); + assert!(html.contains("<new>")); } #[test] @@ -1522,7 +2504,6 @@ mod tests { assert!(html.contains("Page 2 of 3")); assert!(!html.contains("type=\"number\"")); - assert!(!html.contains("$${{")); } #[test] @@ -1535,7 +2516,6 @@ mod tests { assert!(html.contains("value=\"99\"")); assert!(html.contains("value=\"100\"")); assert!(html.contains("Page 50 of 100")); - assert!(!html.contains("$${{")); } #[test] @@ -1546,7 +2526,6 @@ mod tests { assert!(html.contains("action=\"/servers/123\"")); assert!(html.contains("name=\"search\" value=\"2026-08\"")); assert!(html.contains("name=\"search_by\" value=\"timestamp\"")); - assert!(!html.contains("$${{")); } #[test] @@ -1554,10 +2533,9 @@ mod tests { let html = message_search_form("/users/123", "", "timestamp"); assert!(html.contains("action=\"/users/123\"")); - assert!(html.contains("value=\"<date>\"")); + assert!(html.contains("value=\"<date>\"")); assert!(html.contains("value=\"timestamp\" selected")); assert!(!html.contains("value=\"content\" selected")); - assert!(!html.contains("$${{")); } #[test] @@ -1587,7 +2565,6 @@ mod tests { assert!(html.contains("value=\"1\">Previous version")); assert!(html.contains("value=\"3\">Next version")); assert!(html.contains("max=\"3\" value=\"2\"")); - assert!(!html.contains("$${{")); } #[test] @@ -1609,8 +2586,85 @@ mod tests { "2026-02-01".into(), )]); - assert!(html.contains("<old name>")); + assert!(html.contains("<old name>")); assert!(html.contains("first seen 2026-01-01")); - assert!(!html.contains("$${{")); + } + + #[test] + fn channel_permissions_apply_role_and_member_overwrites() { + let roles = HashMap::from([(1, 0), (2, 1 << 10)]); + let overwrites = vec![ + DiscordOverwrite { + id: "2".into(), + kind: 0, + allow: "0".into(), + deny: (1_u64 << 10).to_string(), + }, + DiscordOverwrite { + id: "3".into(), + kind: 1, + allow: (1_u64 << 10).to_string(), + deny: "0".into(), + }, + ]; + + assert!(can_view_channel(1, 3, &[2], &roles, &overwrites)); + assert!(!can_view_channel(1, 4, &[2], &roles, &overwrites)); + } + + #[test] + fn administrator_bypasses_channel_overwrites() { + let roles = HashMap::from([(1, 0), (2, 1 << 3)]); + let overwrites = vec![DiscordOverwrite { + id: "2".into(), + kind: 0, + allow: "0".into(), + deny: u64::MAX.to_string(), + }]; + + assert!(can_view_channel(1, 3, &[2], &roles, &overwrites)); + } + + #[test] + fn explains_role_based_channel_access() { + let roles = HashMap::from([(1, 0), (2, 1 << 10)]); + let role_names = HashMap::from([(1, "@everyone".into()), (2, "Archivist".into())]); + + assert_eq!( + view_channel_reason(1, 3, &[2], &roles, &role_names, &[], "TeenGovernment"), + Some("you have the Archivist role in TeenGovernment".into()) + ); + } + + #[test] + fn explains_server_membership_channel_access() { + let roles = HashMap::from([(1, 1 << 10)]); + let role_names = HashMap::from([(1, "@everyone".into())]); + + assert_eq!( + view_channel_reason(1, 3, &[], &roles, &role_names, &[], "TeenGovernment"), + Some("you are a member of TeenGovernment".into()) + ); + } + + #[test] + fn validates_cookie_themes() { + assert_eq!(Theme::from_cookie(Some("white")).as_str(), "white"); + assert_eq!(Theme::from_cookie(Some("black")).as_str(), "black"); + assert_eq!(Theme::from_cookie(Some("oled")).as_str(), "oled"); + assert_eq!(Theme::from_cookie(Some("unknown")).as_str(), "white"); + } + + #[test] + fn shared_page_contains_the_footer_and_theme_switcher() { + let html = render_page("Archive", "Content", true); + + assert!(html.contains("class=\"theme-white\"")); + assert!(!html.contains("If you cannot see any messages")); + assert!(include_str!("../static/index.html").contains("If you cannot see any messages")); + assert!(html.contains("git.ewenlau.net/ewenlau/tg-archive-bot")); + assert!(html.contains("Copyright © 2026 Ewi and contributors")); + assert!(html.contains("licensed under GPL-3.0")); + assert!(html.contains("action=\"/theme\"")); } } diff --git a/static/arbitrary-page.html b/static/arbitrary-page.html index 65e3307..d86b432 100644 --- a/static/arbitrary-page.html +++ b/static/arbitrary-page.html @@ -1,5 +1,6 @@ -
- -$${{additional_fields}} + + +{% if let Some(search_by) = search_by %} +{% endif %}
diff --git a/static/attachment-value.html b/static/attachment-value.html deleted file mode 100644 index f527169..0000000 --- a/static/attachment-value.html +++ /dev/null @@ -1 +0,0 @@ - — $${{value}} diff --git a/static/attachment.html b/static/attachment.html index 48fcc7f..8265f4a 100644 --- a/static/attachment.html +++ b/static/attachment.html @@ -1 +1 @@ -
  • View $${{filename}} — $${{size}} bytes$${{content_type}}$${{description}}
  • +
  • View {{filename}} - {{size}} bytes{% if let Some(value) = content_type %} - {{value}}{% endif %}{% if let Some(value) = description %} - {{value}}{% endif %}
  • diff --git a/static/channel-status.html b/static/channel-status.html index e281f26..64b522a 100644 --- a/static/channel-status.html +++ b/static/channel-status.html @@ -1,22 +1,23 @@ -

    $${{channel_name}}

    +

    {{channel_name}}

    +

    You can see this channel because {{access_reason}}.

    -
    Channel ID
    $${{channel_id}}
    -
    Server
    $${{server_name}}
    -
    Messages
    $${{message_count}}
    -
    Observed users
    $${{user_count}}
    -
    Message versions
    $${{version_count}}
    -
    Archived data
    $${{total_storage}}
    -
    Message content
    $${{message_storage}}
    -
    Attachments
    $${{attachment_storage}}
    +
    Channel ID
    {{channel_id}}
    +
    Server
    {{server_name}}
    +
    Messages
    {{message_count}}
    +
    Observed users
    {{user_count}}
    +
    Message versions
    {{version_count}}
    +
    Archived data
    {{total_storage}}
    +
    Message content
    {{message_storage}}
    +
    Attachments
    {{attachment_storage}}
    -
    +

    Known names

      -$${{names}} +{{names|safe}}

    Observed users

      -$${{users}} +{{users|safe}}
    diff --git a/static/channel.html b/static/channel.html index 6ed6968..3ea7646 100644 --- a/static/channel.html +++ b/static/channel.html @@ -1 +1 @@ -
  • $${{channel_name}} ($${{channel_id}}) in $${{server_name}}
  • +
  • {{channel_name}} ({{channel_id}}) in {{server_name}}
  • diff --git a/static/current-page.html b/static/current-page.html index 0bfa5db..8ccdc5e 100644 --- a/static/current-page.html +++ b/static/current-page.html @@ -1 +1 @@ -$${{page_number}} +{{page_number}} diff --git a/static/embed-description.html b/static/embed-description.html deleted file mode 100644 index c6c1f41..0000000 --- a/static/embed-description.html +++ /dev/null @@ -1 +0,0 @@ -
    $${{description}}
    diff --git a/static/embed-title.html b/static/embed-title.html deleted file mode 100644 index 860f25c..0000000 --- a/static/embed-title.html +++ /dev/null @@ -1 +0,0 @@ -$${{title}} diff --git a/static/embed-url.html b/static/embed-url.html deleted file mode 100644 index cc160e4..0000000 --- a/static/embed-url.html +++ /dev/null @@ -1 +0,0 @@ -$${{url}} diff --git a/static/embed.html b/static/embed.html index dcb3f6f..b2fb8c2 100644 --- a/static/embed.html +++ b/static/embed.html @@ -1 +1 @@ -
  • $${{title}}$${{description}}$${{url}}
  • +
  • {% if let Some(value) = title %}{{value}}{% endif %}{% if let Some(value) = description %}
    {{value}}
    {% endif %}{% if let Some(value) = url %}{{value}}{% endif %}
  • diff --git a/static/error.html b/static/error.html index 5d37b72..e02a627 100644 --- a/static/error.html +++ b/static/error.html @@ -1,2 +1,2 @@

    Error

    -

    $${{message}}

    +

    {{message}}

    diff --git a/static/index.html b/static/index.html index 76e848e..c1f044f 100644 --- a/static/index.html +++ b/static/index.html @@ -1,8 +1,10 @@

    Archive

    -

    Archived $${{message_count}} messages from $${{user_count}} users across $${{channel_count}} channels on $${{server_count}} servers.

    +

    Archived {{message_count}} messages from {{user_count}} users across {{channel_count}} channels on {{server_count}} servers.

    Storage usage

    -
    Total archive
    $${{total_storage}}
    -
    Messages and metadata
    $${{message_storage}}
    -
    Attachments
    $${{attachment_storage}}
    +
    Total archive
    {{total_storage}}
    +
    Messages and metadata
    {{message_storage}}
    +
    Attachments
    {{attachment_storage}}
    + +

    If you cannot see any messages, try and send a message on a server the bot is in.

    diff --git a/static/list.html b/static/list.html index 5f76634..e2247e6 100644 --- a/static/list.html +++ b/static/list.html @@ -1,7 +1,7 @@ -

    $${{title}}

    -$${{search_form}} -

    $${{item_count}} $${{item_name}} found.

    +

    {{title}}

    +{{search_form|safe}} +

    {{item_count}} {{item_name}} found.

      -$${{items}} +{{items|safe}}
    -$${{pagination}} +{{pagination|safe}} diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..cb8919f --- /dev/null +++ b/static/login.html @@ -0,0 +1,6 @@ +

    Log in

    +

    It is required to log in with Discord to view the archive.

    +

    The login happens on the official Discord website. This site does not have access to your email or password.

    +
    + +
    diff --git a/static/message-list-item.html b/static/message-list-item.html index 568de4b..542c019 100644 --- a/static/message-list-item.html +++ b/static/message-list-item.html @@ -1 +1 @@ -
  • Message $${{message_id}} by $${{author}} in $${{server}} / $${{channel}} at $${{timestamp}} — $${{attachment_count}} attachments, $${{embed_count}} embeds
  • +
  • Message {{message_id}} by {{author}} in {{server}} / {{channel}} at {{timestamp}} - {{attachment_count}} attachments, {{embed_count}} embeds
  • diff --git a/static/message-list.html b/static/message-list.html index 4d0a03b..fb08cd9 100644 --- a/static/message-list.html +++ b/static/message-list.html @@ -1,7 +1,7 @@ -

    $${{title}}

    -$${{search_form}} -

    $${{item_count}} archived messages found.

    +

    {{title}}

    +{{search_form|safe}} +

    {{item_count}} archived messages found.

      -$${{items}} +{{items|safe}}
    -$${{pagination}} +{{pagination|safe}} diff --git a/static/message-search-form.html b/static/message-search-form.html index d494e98..731d549 100644 --- a/static/message-search-form.html +++ b/static/message-search-form.html @@ -1,9 +1,9 @@ -
    - + + diff --git a/static/message-version-button.html b/static/message-version-button.html index 00ba1ef..0ecfe92 100644 --- a/static/message-version-button.html +++ b/static/message-version-button.html @@ -1,3 +1,3 @@ - - + +
    diff --git a/static/message-version-navigation.html b/static/message-version-navigation.html index cc77a8d..20a361a 100644 --- a/static/message-version-navigation.html +++ b/static/message-version-navigation.html @@ -1,9 +1,9 @@ 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 @@ -
    - -$${{additional_fields}} + + +{% if let Some(search_by) = search_by %} +{% endif %}
    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 @@ -
  • Archived user avatar — first seen $${{first_seen}}, last seen $${{last_seen}}
  • +
  • Archived user avatar - 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 @@ -
  • Archived server icon — first seen $${{first_seen}}, last seen $${{last_seen}}
  • +
  • Archived server icon - 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}})