Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93d7382a46 | ||
|
|
c0792d54e9 | ||
|
|
2967b9cb5f | ||
|
|
2648881c00 | ||
|
|
1af68d464d | ||
|
|
52bc46b6d4 | ||
|
|
126b9e9e9f | ||
|
|
96a8c9ea8c | ||
|
|
39fdfd3cde | ||
|
|
9e01b515e9 | ||
|
|
5ffc8122d0 | ||
|
|
0ca1f9d916 | ||
|
|
afc9c16484 | ||
|
|
b5bca1c59a | ||
|
|
48408cba61 |
No files matched your search
+13
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tg-archive-bot"
|
name = "tg-archive-bot"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
description = "Bot for archiving messages in TeenGovernment and related servers"
|
description = "Bot for archiving messages in TeenGovernment and related servers"
|
||||||
@@ -10,11 +10,21 @@ repository = "https://git.ewenlau.net/ewenlau/tg-archive-bot"
|
|||||||
homepage = "https://git.ewenlau.net/ewenlau/tg-archive-bot"
|
homepage = "https://git.ewenlau.net/ewenlau/tg-archive-bot"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
askama = "0.16.0"
|
||||||
axum = { version = "0.8.4", features = ["form"] }
|
axum = { version = "0.8.4", features = ["form"] }
|
||||||
|
axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
chrono-tz = "0.10"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
|
ipnet = "2.12.1"
|
||||||
poise = "0.6.2"
|
poise = "0.6.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
pulldown-cmark = "0.13.4"
|
||||||
|
rand = "0.10.2"
|
||||||
|
reqwest = { version = "0.13.4", features = ["json", "form"] }
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "macros"] }
|
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "macros"] }
|
||||||
tokio = { version = "1.52.3", features = ["full"] }
|
tokio = { version = "1.53.1", features = ["full"] }
|
||||||
|
tower-sessions = "0.15"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
urlencoding = "2"
|
||||||
@@ -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.
|
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.
|
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`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[general]
|
||||||
|
dirs = ["static"]
|
||||||
@@ -31,6 +31,9 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
TG_BOT_DISCORD_TOKEN: YOUR_DISCORD_TOKEN_HERE
|
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_DATABASE_URL: postgres://postgres:YOUR_POSTGRES_PASSWORD_HERE@postgres:5432/tg_archive_bot
|
||||||
TG_BOT_LOG: info
|
TG_BOT_LOG: info
|
||||||
TG_BOT_WEB_ADDRESS: 0.0.0.0:3000
|
TG_BOT_WEB_ADDRESS: 0.0.0.0:3000
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
discord_id BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
FOREIGN KEY (discord_id)
|
||||||
|
REFERENCES discord_users(discord_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_discord_id_idx
|
||||||
|
ON api_tokens(discord_id);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(pool: &PgPool) -> Result<ArchiveStats, sqlx::Error> {
|
||||||
|
let stats = sqlx::query_as::<_, (i64, i64, i64, i64, i64, i64, i64)>(
|
||||||
|
"SELECT
|
||||||
|
(SELECT COUNT(*) FROM messages),
|
||||||
|
(SELECT COUNT(DISTINCT author_id) FROM messages),
|
||||||
|
(SELECT COUNT(*) FROM guilds),
|
||||||
|
(SELECT COUNT(*) FROM channels),
|
||||||
|
(SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0)
|
||||||
|
FROM message_versions)
|
||||||
|
+ (SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0)
|
||||||
|
FROM attachments),
|
||||||
|
(SELECT COALESCE(SUM(OCTET_LENGTH(content)), 0)
|
||||||
|
FROM message_versions),
|
||||||
|
(SELECT COALESCE(SUM(OCTET_LENGTH(data)), 0)
|
||||||
|
FROM attachments);",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(ArchiveStats {
|
||||||
|
messages: stats.0,
|
||||||
|
users: stats.1,
|
||||||
|
servers: stats.2,
|
||||||
|
channels: stats.3,
|
||||||
|
total_storage: stats.4,
|
||||||
|
message_storage: stats.5,
|
||||||
|
attachment_storage: stats.6,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_bytes(bytes: i64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
|
||||||
|
let mut size = bytes.max(0) as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{} {}", bytes.max(0), UNITS[unit])
|
||||||
|
} else {
|
||||||
|
format!("{:.1} {}", size, UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::messaging::{edit_response_message, trace_message};
|
use crate::bot::messaging::{edit_response_message, trace_message};
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ pub async fn git(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
|
|
||||||
let response = ctx.say("Processing...").await?;
|
let response = ctx.say("Processing...").await?;
|
||||||
|
|
||||||
let msg = "Git repo: https://git.ewenlau.net/ewenlau/tg-archive-bot\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.";
|
let msg = "Git repo: https://git.ewenlau.net/ewenlau/tg-archive\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.";
|
||||||
edit_response_message(&response, ctx, msg, true).await?;
|
edit_response_message(&response, ctx, msg, true).await?;
|
||||||
trace_message(
|
trace_message(
|
||||||
msg,
|
msg,
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod ping;
|
pub mod ping;
|
||||||
|
pub mod stats;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use git::git;
|
pub use git::git;
|
||||||
pub use ping::ping;
|
pub use ping::ping;
|
||||||
|
pub use stats::stats;
|
||||||
pub use version::version;
|
pub use version::version;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::messaging::trace_message;
|
use crate::bot::messaging::trace_message;
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error};
|
||||||
use poise::serenity_prelude as sere;
|
use poise::serenity_prelude as sere;
|
||||||
use tracing::{debug, trace};
|
use tracing::{debug, trace};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
use crate::bot::messaging::{trace_message, edit_response_message};
|
||||||
|
use crate::{Context, Error, archive_stats};
|
||||||
|
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 response = ctx.say("Processing...").await?;
|
||||||
|
|
||||||
|
let stats = archive_stats::load(&ctx.data().pool).await?;
|
||||||
|
let msg = format!(
|
||||||
|
"# Archive Statistics\n## Messages\nArchived messages: {}\nArchived users: {}\nChannels: {}\nServers: {}\n## Storage usage\nArchived data: {}\nMessage content: {}\nAttachments: {}",
|
||||||
|
stats.messages,
|
||||||
|
stats.users,
|
||||||
|
stats.channels,
|
||||||
|
stats.servers,
|
||||||
|
archive_stats::format_bytes(stats.total_storage),
|
||||||
|
archive_stats::format_bytes(stats.message_storage),
|
||||||
|
archive_stats::format_bytes(stats.attachment_storage),
|
||||||
|
);
|
||||||
|
|
||||||
|
edit_response_message(&response, ctx, &msg, true).await?;
|
||||||
|
trace_message(
|
||||||
|
&msg,
|
||||||
|
ctx.channel_id().to_string(),
|
||||||
|
ctx.guild_id().unwrap().to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
debug!("Stats command performed for user {}", ctx.author().name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::messaging::trace_message;
|
use crate::bot::messaging::{trace_message, edit_response_message};
|
||||||
use crate::{Context, Error};
|
use crate::{Context, Error};
|
||||||
use tracing::{debug, trace};
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ pub async fn version(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
ctx.guild_id().unwrap().get()
|
ctx.guild_id().unwrap().get()
|
||||||
);
|
);
|
||||||
trace!("Loading embedded version information");
|
trace!("Loading embedded version information");
|
||||||
|
let response = ctx.say("Processing...").await?;
|
||||||
let msg = format!("Current version:\n{VERSION}");
|
let msg = format!("Current version:\n{VERSION}");
|
||||||
trace_message(
|
trace_message(
|
||||||
&msg,
|
&msg,
|
||||||
@@ -20,7 +21,7 @@ pub async fn version(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
ctx.guild_id().unwrap().to_string(),
|
ctx.guild_id().unwrap().to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
ctx.say(msg).await?;
|
edit_response_message(&response, ctx, &msg, true).await?;
|
||||||
debug!(
|
debug!(
|
||||||
"Version command performed for user {} with version information",
|
"Version command performed for user {} with version information",
|
||||||
ctx.author().name
|
ctx.author().name
|
||||||
@@ -10,7 +10,7 @@ pub async fn event_handler(
|
|||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match event {
|
match event {
|
||||||
sere::FullEvent::Message { new_message } => {
|
sere::FullEvent::Message { new_message } => {
|
||||||
crate::message_archive::record_message(ctx, new_message, &user_data.pool).await?;
|
super::message_archive::record_message(ctx, new_message, &user_data.pool).await?;
|
||||||
}
|
}
|
||||||
sere::FullEvent::MessageUpdate {
|
sere::FullEvent::MessageUpdate {
|
||||||
old_if_available,
|
old_if_available,
|
||||||
@@ -43,7 +43,7 @@ pub async fn event_handler(
|
|||||||
message = event.channel_id.message(ctx, event.id).await?;
|
message = event.channel_id.message(ctx, event.id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
crate::message_archive::record_message_edit(ctx, &message, &user_data.pool).await?;
|
super::message_archive::record_message_edit(ctx, &message, &user_data.pool).await?;
|
||||||
}
|
}
|
||||||
sere::FullEvent::MessageDelete {
|
sere::FullEvent::MessageDelete {
|
||||||
deleted_message_id, ..
|
deleted_message_id, ..
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod event_handler;
|
||||||
|
pub mod message_archive;
|
||||||
|
pub mod messaging;
|
||||||
+33
-12
@@ -2,17 +2,14 @@ use poise::serenity_prelude as sere;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use tracing::{debug, error, info, trace};
|
use tracing::{debug, error, info, trace};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
pub mod commands;
|
mod archive_stats;
|
||||||
pub mod event_handler;
|
mod bot;
|
||||||
pub mod message_archive;
|
mod web;
|
||||||
pub mod messaging;
|
|
||||||
pub mod web;
|
|
||||||
|
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
pub pool: sqlx::PgPool,
|
pub pool: sqlx::PgPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define standard types for Poise context, commands, and errors.
|
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
pub type Context<'a> = poise::Context<'a, Data, Error>;
|
||||||
|
|
||||||
@@ -37,7 +34,6 @@ async fn main() {
|
|||||||
|
|
||||||
trace!("Env loaded");
|
trace!("Env loaded");
|
||||||
|
|
||||||
// Grab envs
|
|
||||||
trace!("Loading database url");
|
trace!("Loading database url");
|
||||||
let database_url =
|
let database_url =
|
||||||
env::var("TG_BOT_DATABASE_URL").expect("Expected a database url in the environment");
|
env::var("TG_BOT_DATABASE_URL").expect("Expected a database url in the environment");
|
||||||
@@ -51,9 +47,15 @@ async fn main() {
|
|||||||
let web_address = env::var("TG_BOT_WEB_ADDRESS").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
|
let web_address = env::var("TG_BOT_WEB_ADDRESS").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
|
||||||
trace!("Web address loaded");
|
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...");
|
info!("Starting bot...");
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
debug!("Initalizing database");
|
debug!("Initalizing database");
|
||||||
let pool = connect_database(&database_url)
|
let pool = connect_database(&database_url)
|
||||||
.await
|
.await
|
||||||
@@ -71,7 +73,14 @@ async fn main() {
|
|||||||
let web_listener = tokio::net::TcpListener::bind(&web_address)
|
let web_listener = tokio::net::TcpListener::bind(&web_address)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to bind web server");
|
.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");
|
debug!("Web server started");
|
||||||
|
|
||||||
trace!("Loading intents");
|
trace!("Loading intents");
|
||||||
@@ -84,7 +93,7 @@ async fn main() {
|
|||||||
let framework = poise::Framework::builder()
|
let framework = poise::Framework::builder()
|
||||||
.options(poise::FrameworkOptions {
|
.options(poise::FrameworkOptions {
|
||||||
event_handler: |ctx, event, framework, user_data| {
|
event_handler: |ctx, event, framework, user_data| {
|
||||||
Box::pin(event_handler::event_handler(
|
Box::pin(bot::event_handler::event_handler(
|
||||||
ctx, event, framework, user_data,
|
ctx, event, framework, user_data,
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
@@ -102,7 +111,12 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
commands: vec![commands::ping(), commands::version(), commands::git()],
|
commands: vec![
|
||||||
|
bot::commands::ping(),
|
||||||
|
bot::commands::version(),
|
||||||
|
bot::commands::git(),
|
||||||
|
bot::commands::stats(),
|
||||||
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.setup(|ctx, _ready, framework| {
|
.setup(|ctx, _ready, framework| {
|
||||||
@@ -171,5 +185,12 @@ async fn connect_database(database_url: &str) -> Result<sqlx::PgPool, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn quote_identifier(identifier: &str) -> String {
|
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)
|
||||||
}
|
}
|
||||||
-1616
File diff suppressed because it is too large.
Load diff
+986
@@ -0,0 +1,986 @@
|
|||||||
|
use super::{
|
||||||
|
CHANNEL_ACCESS_TTL_SECONDS, ChannelAccess, WebData, WebUser, accessible_channels, safe_filename,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Extension, Json, Router,
|
||||||
|
extract::{ConnectInfo, Path, Query, Request, State},
|
||||||
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||||
|
middleware::{self, Next},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use rand::{RngExt, distr::Alphanumeric};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
const RESULTS_PER_PAGE: i64 = 100;
|
||||||
|
const TOKEN_RATE_LIMIT: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub(super) struct ApiPermissionCache {
|
||||||
|
users: Arc<Mutex<HashMap<i64, (Instant, Vec<ChannelAccess>)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiPermissionCache {
|
||||||
|
fn get_at(&self, discord_id: i64, now: Instant) -> Option<Vec<ChannelAccess>> {
|
||||||
|
let mut users = self.users.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
users.retain(|_, (refreshed_at, _)| {
|
||||||
|
now.saturating_duration_since(*refreshed_at).as_secs() < CHANNEL_ACCESS_TTL_SECONDS
|
||||||
|
});
|
||||||
|
users
|
||||||
|
.get(&discord_id)
|
||||||
|
.map(|(_, channel_access)| channel_access.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, discord_id: i64) -> Option<Vec<ChannelAccess>> {
|
||||||
|
self.get_at(discord_id, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_at(&self, discord_id: i64, channel_access: Vec<ChannelAccess>, now: Instant) {
|
||||||
|
self.users
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.insert(discord_id, (now, channel_access));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&self, discord_id: i64, channel_access: Vec<ChannelAccess>) {
|
||||||
|
self.insert_at(discord_id, channel_access, Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cached_accessible_channels(
|
||||||
|
data: &WebData,
|
||||||
|
discord_id: i64,
|
||||||
|
) -> Result<Vec<ChannelAccess>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if let Some(channel_access) = data.api_permission_cache.get(discord_id) {
|
||||||
|
return Ok(channel_access);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_access = accessible_channels(data, discord_id).await?;
|
||||||
|
data.api_permission_cache
|
||||||
|
.insert(discord_id, channel_access.clone());
|
||||||
|
Ok(channel_access)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(super) struct TokenRateLimiter {
|
||||||
|
attempts: Arc<Mutex<HashMap<IpAddr, Instant>>>,
|
||||||
|
trusted_proxies: Arc<Vec<ipnet::IpNet>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenRateLimiter {
|
||||||
|
pub(super) fn from_env() -> Result<Self, String> {
|
||||||
|
let trusted_proxies = env::var("TG_BOT_TRUSTED_PROXY_RANGES")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|range| !range.is_empty())
|
||||||
|
.map(|range| {
|
||||||
|
range
|
||||||
|
.parse::<ipnet::IpNet>()
|
||||||
|
.or_else(|_| range.parse::<IpAddr>().map(ipnet::IpNet::from))
|
||||||
|
.map_err(|_| format!("invalid trusted proxy IP range: {range}"))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(Self {
|
||||||
|
attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
trusted_proxies: Arc::new(trusted_proxies),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr {
|
||||||
|
if self
|
||||||
|
.trusted_proxies
|
||||||
|
.iter()
|
||||||
|
.any(|range| range.contains(&peer))
|
||||||
|
{
|
||||||
|
headers
|
||||||
|
.get("x-real-ip")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.trim().parse().ok())
|
||||||
|
.unwrap_or(peer)
|
||||||
|
} else {
|
||||||
|
peer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ip: IpAddr) -> Result<(), u64> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut attempts = self
|
||||||
|
.attempts
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
attempts.retain(|_, attempt| now.duration_since(*attempt) < TOKEN_RATE_LIMIT);
|
||||||
|
if let Some(attempt) = attempts.get(&ip) {
|
||||||
|
let remaining = TOKEN_RATE_LIMIT.saturating_sub(now.duration_since(*attempt));
|
||||||
|
return Err(remaining.as_secs().max(1));
|
||||||
|
}
|
||||||
|
attempts.insert(ip, now);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ApiUser {
|
||||||
|
discord_id: i64,
|
||||||
|
channel_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ErrorResponse {
|
||||||
|
error: &'static str,
|
||||||
|
}
|
||||||
|
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<ErrorResponse>)>;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MeResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ServerResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
name: String,
|
||||||
|
icon_url: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChannelResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
name: String,
|
||||||
|
server_id: i64,
|
||||||
|
server_name: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
username: String,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
first_seen_at: Option<String>,
|
||||||
|
last_seen_at: Option<String>,
|
||||||
|
message_count: i64,
|
||||||
|
server_count: i64,
|
||||||
|
channel_count: i64,
|
||||||
|
attachment_count: i64,
|
||||||
|
embed_count: i64,
|
||||||
|
usernames: Vec<UserNameResponse>,
|
||||||
|
avatars: Vec<UserAvatarResponse>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
struct UserNameResponse {
|
||||||
|
username: String,
|
||||||
|
first_seen_at: String,
|
||||||
|
last_seen_at: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
struct UserAvatarResponse {
|
||||||
|
url: String,
|
||||||
|
first_seen_at: String,
|
||||||
|
last_seen_at: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
struct AttachmentResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
filename: String,
|
||||||
|
description: Option<String>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
size: i64,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
struct EmbedResponse {
|
||||||
|
index: i32,
|
||||||
|
title: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MessageResponse {
|
||||||
|
discord_id: i64,
|
||||||
|
author_id: i64,
|
||||||
|
author_username: String,
|
||||||
|
server_id: i64,
|
||||||
|
server_name: String,
|
||||||
|
channel_id: i64,
|
||||||
|
channel_name: String,
|
||||||
|
timestamp: String,
|
||||||
|
version: i64,
|
||||||
|
archived_at: String,
|
||||||
|
content: Option<String>,
|
||||||
|
attachments: Vec<AttachmentResponse>,
|
||||||
|
embeds: Vec<EmbedResponse>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
struct MessageSummary {
|
||||||
|
discord_id: i64,
|
||||||
|
author_id: i64,
|
||||||
|
author_username: String,
|
||||||
|
server_id: i64,
|
||||||
|
server_name: String,
|
||||||
|
channel_id: i64,
|
||||||
|
channel_name: String,
|
||||||
|
timestamp: String,
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FilteredMessageSummary {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
discord_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
author_username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
server_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
server_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
channel_id: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
channel_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
timestamp: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SearchResponse {
|
||||||
|
query: String,
|
||||||
|
limit: i64,
|
||||||
|
results: Vec<FilteredMessageSummary>,
|
||||||
|
}
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct VersionQuery {
|
||||||
|
version: Option<i64>,
|
||||||
|
}
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct SearchQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
q: String,
|
||||||
|
page: Option<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_FIELDS: [&str; 9] = [
|
||||||
|
"discord_id",
|
||||||
|
"author_id",
|
||||||
|
"author_username",
|
||||||
|
"server_id",
|
||||||
|
"server_name",
|
||||||
|
"channel_id",
|
||||||
|
"channel_name",
|
||||||
|
"timestamp",
|
||||||
|
"content",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum MetadataType {
|
||||||
|
#[default]
|
||||||
|
Message,
|
||||||
|
Server,
|
||||||
|
Channel,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum MetadataLookup {
|
||||||
|
#[default]
|
||||||
|
MessageCount,
|
||||||
|
FirstMessage,
|
||||||
|
LastMessage,
|
||||||
|
AttachmentCount,
|
||||||
|
EmbedCount,
|
||||||
|
// For channels or servers
|
||||||
|
UserCount,
|
||||||
|
// For servers
|
||||||
|
ChannelCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataLookup {
|
||||||
|
fn supports(self, metadata_type: MetadataType) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::UserCount => {
|
||||||
|
matches!(metadata_type, MetadataType::Server | MetadataType::Channel)
|
||||||
|
}
|
||||||
|
Self::ChannelCount => matches!(metadata_type, MetadataType::Server),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct MetadataQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
mtype: MetadataType,
|
||||||
|
id: i64,
|
||||||
|
ltype: MetadataLookup,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn router(data: WebData) -> Router {
|
||||||
|
let protected = Router::new()
|
||||||
|
.route("/me", get(me))
|
||||||
|
.route("/view/message/{id}", get(view_message))
|
||||||
|
.route("/view/server/{id}", get(view_server))
|
||||||
|
.route("/view/channel/{id}", get(view_channel))
|
||||||
|
.route("/view/user/{id}", get(view_user))
|
||||||
|
// Added attachement and attachment because I make the mistake often
|
||||||
|
.route("/view/attachment/{id}", get(view_attachment))
|
||||||
|
.route("/view/attachement/{id}", get(view_attachment))
|
||||||
|
.route("/download/attachment/{id}", get(download_attachment))
|
||||||
|
.route("/download/attachement/{id}", get(download_attachment))
|
||||||
|
.route("/search/timestamp", get(search_timestamp))
|
||||||
|
.route("/search/content", get(search_content))
|
||||||
|
.route("/metadata", get(metadata_lookup))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
data.clone(),
|
||||||
|
require_api_user,
|
||||||
|
));
|
||||||
|
Router::new()
|
||||||
|
.route("/token", post(create_token))
|
||||||
|
.merge(protected)
|
||||||
|
.with_state(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_api_user(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(token) = bearer_token(request.headers().get(header::AUTHORIZATION)) else {
|
||||||
|
return api_error(StatusCode::UNAUTHORIZED, "invalid or missing bearer token");
|
||||||
|
};
|
||||||
|
let discord_id =
|
||||||
|
match sqlx::query_scalar::<_, i64>("SELECT discord_id FROM api_tokens WHERE token = $1")
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(id)) => id,
|
||||||
|
Ok(None) => {
|
||||||
|
return api_error(StatusCode::UNAUTHORIZED, "invalid or missing bearer token");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!("API token lookup failed: {}", error);
|
||||||
|
return api_error(StatusCode::INTERNAL_SERVER_ERROR, "database error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let channel_ids = match cached_accessible_channels(&data, discord_id).await {
|
||||||
|
Ok(access) => access
|
||||||
|
.into_iter()
|
||||||
|
.map(|channel| channel.channel_id)
|
||||||
|
.collect(),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!("API permission refresh failed: {}", error);
|
||||||
|
return api_error(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"could not refresh Discord permissions",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.extensions_mut().insert(ApiUser {
|
||||||
|
discord_id,
|
||||||
|
channel_ids,
|
||||||
|
});
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_token(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
session: Session,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response {
|
||||||
|
let user = match session.get::<WebUser>("user").await {
|
||||||
|
Ok(Some(user)) => user,
|
||||||
|
Ok(None) => return api_error(StatusCode::UNAUTHORIZED, "browser login required"),
|
||||||
|
Err(_) => return api_error(StatusCode::INTERNAL_SERVER_ERROR, "session error"),
|
||||||
|
};
|
||||||
|
let client_ip = data.token_rate_limiter.client_ip(peer.ip(), &headers);
|
||||||
|
if let Err(retry_after) = data.token_rate_limiter.check(client_ip) {
|
||||||
|
return rate_limited(retry_after);
|
||||||
|
}
|
||||||
|
let token: String = rand::rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(64)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
let mut transaction = match data.pool.begin().await {
|
||||||
|
Ok(transaction) => transaction,
|
||||||
|
Err(error_value) => return database(error_value).into_response(),
|
||||||
|
};
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO discord_users (discord_id, discord_username)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (discord_id) DO UPDATE
|
||||||
|
SET discord_username = EXCLUDED.discord_username",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&user.username)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await;
|
||||||
|
if let Err(error_value) = result {
|
||||||
|
return database(error_value).into_response();
|
||||||
|
}
|
||||||
|
let result = sqlx::query("INSERT INTO api_tokens (token, discord_id) VALUES ($1, $2)")
|
||||||
|
.bind(&token)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await;
|
||||||
|
if let Err(error_value) = result {
|
||||||
|
return database(error_value).into_response();
|
||||||
|
}
|
||||||
|
if let Err(error_value) = transaction.commit().await {
|
||||||
|
return database(error_value).into_response();
|
||||||
|
}
|
||||||
|
Json(TokenResponse { token }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn me(Extension(user): Extension<ApiUser>) -> Json<MeResponse> {
|
||||||
|
Json(MeResponse {
|
||||||
|
discord_id: user.discord_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view_server(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<ServerResponse> {
|
||||||
|
let row = sqlx::query_as::<_, (i64, String, Option<String>)>(
|
||||||
|
"SELECT DISTINCT g.guild_id, g.guild_name, g.guild_icon_url FROM guilds g
|
||||||
|
JOIN channels c ON c.guild_id = g.guild_id
|
||||||
|
WHERE g.guild_id = $1 AND c.channel_id = ANY($2)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
Ok(Json(ServerResponse {
|
||||||
|
discord_id: row.0,
|
||||||
|
name: row.1,
|
||||||
|
icon_url: row.2,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view_attachment(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(query): Query<VersionQuery>,
|
||||||
|
) -> ApiResult<AttachmentResponse> {
|
||||||
|
let row = sqlx::query_as::<_, AttachmentResponse>(
|
||||||
|
"SELECT attachment_id AS discord_id, filename, description, content_type, size FROM attachments a
|
||||||
|
JOIN messages m ON m.message_id = a.message_id
|
||||||
|
WHERE a.attachment_id = $1 AND (m.channel_id = ANY($2) OR m.author_id = $3)
|
||||||
|
AND ($4::bigint IS NULL OR a.message_version = $4)
|
||||||
|
ORDER BY a.message_version DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.bind(query.version)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
Ok(Json(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_attachment(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(query): Query<VersionQuery>,
|
||||||
|
) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let row = sqlx::query_as::<_, (String, Option<String>, Vec<u8>)>(
|
||||||
|
"SELECT a.filename, a.content_type, a.data FROM attachments a
|
||||||
|
JOIN messages m ON m.message_id = a.message_id
|
||||||
|
WHERE a.attachment_id = $1 AND (m.channel_id = ANY($2) OR m.author_id = $3)
|
||||||
|
AND ($4::bigint IS NULL OR a.message_version = $4)
|
||||||
|
ORDER BY a.message_version DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.bind(query.version)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
|
||||||
|
let content_type = row
|
||||||
|
.1
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_owned());
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", safe_filename(&row.0)),
|
||||||
|
),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()),
|
||||||
|
],
|
||||||
|
row.2,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view_channel(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<ChannelResponse> {
|
||||||
|
let row = sqlx::query_as::<_, (i64, String, i64, String)>(
|
||||||
|
"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_id = $1 AND c.channel_id = ANY($2)")
|
||||||
|
.bind(id).bind(&user.channel_ids).fetch_optional(&data.pool).await.map_err(database)?.ok_or_else(not_found)?;
|
||||||
|
Ok(Json(ChannelResponse {
|
||||||
|
discord_id: row.0,
|
||||||
|
name: row.1,
|
||||||
|
server_id: row.2,
|
||||||
|
server_name: row.3,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view_user(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<UserResponse> {
|
||||||
|
let row = sqlx::query_as::<_, (i64, String)>(
|
||||||
|
"SELECT u.discord_id, u.discord_username FROM discord_users u WHERE u.discord_id = $1
|
||||||
|
AND (u.discord_id = $2 OR EXISTS (SELECT 1 FROM messages m
|
||||||
|
WHERE m.author_id = u.discord_id AND m.channel_id = ANY($3)))",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
let history = sqlx::query_as::<_, (Option<String>, Option<String>, Option<String>)>(
|
||||||
|
"SELECT
|
||||||
|
(ARRAY_AGG(discord_avatar_url ORDER BY last_seen_at DESC)
|
||||||
|
FILTER (WHERE discord_avatar_url IS NOT NULL))[1],
|
||||||
|
MIN(first_seen_at)::text,
|
||||||
|
MAX(last_seen_at)::text
|
||||||
|
FROM discord_user_history WHERE discord_id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
let counts = sqlx::query_as::<_, (i64, i64, i64, i64, i64)>(
|
||||||
|
"SELECT COUNT(DISTINCT m.message_id), COUNT(DISTINCT m.guild_id),
|
||||||
|
COUNT(DISTINCT m.channel_id), COUNT(DISTINCT a.uuid), COUNT(DISTINCT e.uuid)
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN attachments a ON a.message_id = m.message_id
|
||||||
|
LEFT JOIN embeds e ON e.message_id = m.message_id
|
||||||
|
WHERE m.author_id = $1 AND (m.channel_id = ANY($2) OR m.author_id = $3)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.fetch_one(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
let usernames = sqlx::query_as::<_, UserNameResponse>(
|
||||||
|
"SELECT discord_username AS username, MIN(first_seen_at)::text AS first_seen_at,
|
||||||
|
MAX(last_seen_at)::text AS last_seen_at
|
||||||
|
FROM discord_user_history WHERE discord_id = $1
|
||||||
|
GROUP BY discord_username ORDER BY MIN(first_seen_at), discord_username",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
let avatars = sqlx::query_as::<_, UserAvatarResponse>(
|
||||||
|
"SELECT discord_avatar_url AS url, MIN(first_seen_at)::text AS first_seen_at,
|
||||||
|
MAX(last_seen_at)::text AS last_seen_at
|
||||||
|
FROM discord_user_history
|
||||||
|
WHERE discord_id = $1 AND discord_avatar_url IS NOT NULL
|
||||||
|
GROUP BY discord_avatar_url ORDER BY MIN(first_seen_at), discord_avatar_url",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
Ok(Json(UserResponse {
|
||||||
|
discord_id: row.0,
|
||||||
|
username: row.1,
|
||||||
|
avatar_url: history.0,
|
||||||
|
first_seen_at: history.1,
|
||||||
|
last_seen_at: history.2,
|
||||||
|
message_count: counts.0,
|
||||||
|
server_count: counts.1,
|
||||||
|
channel_count: counts.2,
|
||||||
|
attachment_count: counts.3,
|
||||||
|
embed_count: counts.4,
|
||||||
|
usernames,
|
||||||
|
avatars,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn view_message(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Query(query): Query<VersionQuery>,
|
||||||
|
) -> ApiResult<MessageResponse> {
|
||||||
|
let message = sqlx::query_as::<_, (i64, i64, String, i64, String, i64, String, String)>(
|
||||||
|
"SELECT m.message_id, m.author_id, m.author_username, m.guild_id, g.guild_name,
|
||||||
|
m.channel_id, c.channel_name, m.timestamp::text FROM messages m
|
||||||
|
JOIN guilds g ON g.guild_id = m.guild_id JOIN channels c ON c.channel_id = m.channel_id
|
||||||
|
WHERE m.message_id = $1 AND (m.channel_id = ANY($2) OR m.author_id = $3)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.fetch_optional(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
let version = sqlx::query_as::<_, (i64, Option<String>, String)>(
|
||||||
|
"SELECT version, content, archived_at::text FROM message_versions
|
||||||
|
WHERE message_id = $1 AND ($2::bigint IS NULL OR version = $2) ORDER BY version DESC LIMIT 1")
|
||||||
|
.bind(id).bind(query.version).fetch_optional(&data.pool).await.map_err(database)?.ok_or_else(not_found)?;
|
||||||
|
let attachments = sqlx::query_as::<_, AttachmentResponse>(
|
||||||
|
"SELECT attachment_id AS discord_id, filename, description, content_type, size FROM attachments
|
||||||
|
WHERE message_id = $1 AND message_version = $2 ORDER BY attachment_id")
|
||||||
|
.bind(id).bind(version.0).fetch_all(&data.pool).await.map_err(database)?;
|
||||||
|
let embeds = sqlx::query_as::<_, EmbedResponse>(
|
||||||
|
"SELECT embed_index AS index, title, description, url FROM embeds
|
||||||
|
WHERE message_id = $1 AND message_version = $2 ORDER BY embed_index",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(version.0)
|
||||||
|
.fetch_all(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
Ok(Json(MessageResponse {
|
||||||
|
discord_id: message.0,
|
||||||
|
author_id: message.1,
|
||||||
|
author_username: message.2,
|
||||||
|
server_id: message.3,
|
||||||
|
server_name: message.4,
|
||||||
|
channel_id: message.5,
|
||||||
|
channel_name: message.6,
|
||||||
|
timestamp: message.7,
|
||||||
|
version: version.0,
|
||||||
|
content: version.1,
|
||||||
|
archived_at: version.2,
|
||||||
|
attachments,
|
||||||
|
embeds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search_timestamp(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> ApiResult<SearchResponse> {
|
||||||
|
search(&data, &user, &query, &query.limit.unwrap_or(100), true).await
|
||||||
|
}
|
||||||
|
async fn search_content(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
) -> ApiResult<SearchResponse> {
|
||||||
|
search(&data, &user, &query, &query.limit.unwrap_or(100), false).await
|
||||||
|
}
|
||||||
|
async fn search(
|
||||||
|
data: &WebData,
|
||||||
|
user: &ApiUser,
|
||||||
|
query: &SearchQuery,
|
||||||
|
limit: &i64,
|
||||||
|
timestamp: bool,
|
||||||
|
) -> ApiResult<SearchResponse> {
|
||||||
|
let limit = limit.max(&1);
|
||||||
|
let pattern = format!("%{}%", query.q);
|
||||||
|
let fields = parse_search_filter(query.filter.as_deref())?;
|
||||||
|
|
||||||
|
let results = sqlx::query_as::<_, MessageSummary>(
|
||||||
|
"SELECT m.message_id AS discord_id, m.author_id, m.author_username, m.guild_id AS server_id,
|
||||||
|
g.guild_name AS server_name, m.channel_id, c.channel_name, m.timestamp::text AS timestamp, m.content
|
||||||
|
FROM messages m
|
||||||
|
JOIN guilds g ON g.guild_id = m.guild_id
|
||||||
|
JOIN channels c ON c.channel_id = m.channel_id
|
||||||
|
WHERE CASE
|
||||||
|
WHEN $2 THEN m.timestamp::text ILIKE $1
|
||||||
|
ELSE COALESCE(m.content, '') ILIKE $1
|
||||||
|
END
|
||||||
|
AND (m.channel_id = ANY($3) OR m.author_id = $4)
|
||||||
|
ORDER BY m.timestamp DESC, m.message_id DESC
|
||||||
|
LIMIT $5"
|
||||||
|
)
|
||||||
|
.bind(&pattern)
|
||||||
|
.bind(timestamp)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?;
|
||||||
|
|
||||||
|
let results = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|result| FilteredMessageSummary {
|
||||||
|
discord_id: fields[0].then_some(result.discord_id),
|
||||||
|
author_id: fields[1].then_some(result.author_id),
|
||||||
|
author_username: fields[2].then_some(result.author_username),
|
||||||
|
server_id: fields[3].then_some(result.server_id),
|
||||||
|
server_name: fields[4].then_some(result.server_name),
|
||||||
|
channel_id: fields[5].then_some(result.channel_id),
|
||||||
|
channel_name: fields[6].then_some(result.channel_name),
|
||||||
|
timestamp: fields[7].then_some(result.timestamp),
|
||||||
|
content: fields[8].then_some(result.content),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(SearchResponse {
|
||||||
|
query: query.q.clone(),
|
||||||
|
limit: *limit,
|
||||||
|
results,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_search_filter(
|
||||||
|
filter: Option<&str>,
|
||||||
|
) -> Result<[bool; 9], (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let Some(filter) = filter else {
|
||||||
|
return Ok([true; 9]);
|
||||||
|
};
|
||||||
|
let mut selected = [false; 9];
|
||||||
|
for field in filter.split(',').map(str::trim) {
|
||||||
|
let Some(index) = SEARCH_FIELDS
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == field)
|
||||||
|
else {
|
||||||
|
return Err(error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid search filter field",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
selected[index] = true;
|
||||||
|
}
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metadata_lookup(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<ApiUser>,
|
||||||
|
Query(query): Query<MetadataQuery>,
|
||||||
|
) -> ApiResult<i64> {
|
||||||
|
if !query.ltype.supports(query.mtype) {
|
||||||
|
return Err(error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"metadata lookup is not supported for this type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entity_filter = match query.mtype {
|
||||||
|
MetadataType::Message => "m.message_id = $1",
|
||||||
|
MetadataType::Server => "m.guild_id = $1",
|
||||||
|
MetadataType::Channel => "m.channel_id = $1",
|
||||||
|
MetadataType::User => "m.author_id = $1",
|
||||||
|
};
|
||||||
|
let aggregate = match query.ltype {
|
||||||
|
MetadataLookup::MessageCount => "COUNT(*)",
|
||||||
|
MetadataLookup::FirstMessage => {
|
||||||
|
"(ARRAY_AGG(message_id ORDER BY timestamp ASC, message_id ASC))[1]"
|
||||||
|
}
|
||||||
|
MetadataLookup::LastMessage => {
|
||||||
|
"(ARRAY_AGG(message_id ORDER BY timestamp DESC, message_id DESC))[1]"
|
||||||
|
}
|
||||||
|
MetadataLookup::AttachmentCount => {
|
||||||
|
"(SELECT COUNT(*) FROM attachments a WHERE a.message_id = visible.message_id)"
|
||||||
|
}
|
||||||
|
MetadataLookup::EmbedCount => {
|
||||||
|
"(SELECT COUNT(*) FROM embeds e WHERE e.message_id = visible.message_id)"
|
||||||
|
}
|
||||||
|
MetadataLookup::UserCount => "COUNT(DISTINCT author_id)",
|
||||||
|
MetadataLookup::ChannelCount => "COUNT(DISTINCT channel_id)",
|
||||||
|
};
|
||||||
|
|
||||||
|
let statement = if matches!(
|
||||||
|
query.ltype,
|
||||||
|
MetadataLookup::AttachmentCount | MetadataLookup::EmbedCount
|
||||||
|
) {
|
||||||
|
format!(
|
||||||
|
"SELECT COALESCE(SUM({aggregate}), 0)::bigint FROM (
|
||||||
|
SELECT m.message_id, m.author_id, m.channel_id, m.timestamp
|
||||||
|
FROM messages m
|
||||||
|
WHERE {entity_filter}
|
||||||
|
AND (m.channel_id = ANY($2) OR m.author_id = $3)
|
||||||
|
) visible"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT {aggregate} FROM (
|
||||||
|
SELECT m.message_id, m.author_id, m.channel_id, m.timestamp
|
||||||
|
FROM messages m
|
||||||
|
WHERE {entity_filter}
|
||||||
|
AND (m.channel_id = ANY($2) OR m.author_id = $3)
|
||||||
|
) visible"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = sqlx::query_scalar::<_, Option<i64>>(sqlx::AssertSqlSafe(statement))
|
||||||
|
.bind(query.id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.bind(user.discord_id)
|
||||||
|
.fetch_one(&data.pool)
|
||||||
|
.await
|
||||||
|
.map_err(database)?
|
||||||
|
.ok_or_else(not_found)?;
|
||||||
|
|
||||||
|
Ok(Json(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bearer_token(value: Option<&axum::http::HeaderValue>) -> Option<&str> {
|
||||||
|
let value = value?.to_str().ok()?;
|
||||||
|
let (scheme, token) = value.split_once(' ')?;
|
||||||
|
(scheme.eq_ignore_ascii_case("Bearer")
|
||||||
|
&& !token.is_empty()
|
||||||
|
&& !token.contains(char::is_whitespace))
|
||||||
|
.then_some(token)
|
||||||
|
}
|
||||||
|
fn database(error_value: sqlx::Error) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
tracing::error!("API database query failed: {}", error_value);
|
||||||
|
error(StatusCode::INTERNAL_SERVER_ERROR, "database error")
|
||||||
|
}
|
||||||
|
fn not_found() -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
error(StatusCode::NOT_FOUND, "not found")
|
||||||
|
}
|
||||||
|
fn error(status: StatusCode, message: &'static str) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
(status, Json(ErrorResponse { error: message }))
|
||||||
|
}
|
||||||
|
fn api_error(status: StatusCode, message: &'static str) -> Response {
|
||||||
|
error(status, message).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rate_limited(retry_after: u64) -> Response {
|
||||||
|
let mut response = api_error(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"token creation rate limit exceeded",
|
||||||
|
);
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::RETRY_AFTER,
|
||||||
|
HeaderValue::from_str(&retry_after.to_string()).unwrap(),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn limiter(trusted_proxies: &[&str]) -> TokenRateLimiter {
|
||||||
|
TokenRateLimiter {
|
||||||
|
attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
trusted_proxies: Arc::new(
|
||||||
|
trusted_proxies
|
||||||
|
.iter()
|
||||||
|
.map(|range| range.parse().unwrap())
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn parses_bearer_tokens() {
|
||||||
|
let header = axum::http::HeaderValue::from_static("Bearer secret-token");
|
||||||
|
assert_eq!(bearer_token(Some(&header)), Some("secret-token"));
|
||||||
|
let lowercase = axum::http::HeaderValue::from_static("bearer another-token");
|
||||||
|
assert_eq!(bearer_token(Some(&lowercase)), Some("another-token"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_authorization_headers() {
|
||||||
|
let basic = axum::http::HeaderValue::from_static("Basic credentials");
|
||||||
|
let empty = axum::http::HeaderValue::from_static("Bearer ");
|
||||||
|
let spaced = axum::http::HeaderValue::from_static("Bearer two tokens");
|
||||||
|
assert_eq!(bearer_token(None), None);
|
||||||
|
assert_eq!(bearer_token(Some(&basic)), None);
|
||||||
|
assert_eq!(bearer_token(Some(&empty)), None);
|
||||||
|
assert_eq!(bearer_token(Some(&spaced)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_filter_selects_requested_fields() {
|
||||||
|
let selected = parse_search_filter(Some("discord_id, content"));
|
||||||
|
assert_eq!(
|
||||||
|
selected.ok(),
|
||||||
|
Some([true, false, false, false, false, false, false, false, true])
|
||||||
|
);
|
||||||
|
assert_eq!(parse_search_filter(None).ok(), Some([true; 9]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_filter_rejects_unknown_and_empty_fields() {
|
||||||
|
assert!(parse_search_filter(Some("unknown")).is_err());
|
||||||
|
assert!(parse_search_filter(Some("")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_trusts_forwarded_ip_from_configured_proxies() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.10"));
|
||||||
|
let limiter = limiter(&["10.0.0.0/8"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
limiter.client_ip("10.1.2.3".parse().unwrap(), &headers),
|
||||||
|
"203.0.113.10".parse::<IpAddr>().unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.client_ip("192.0.2.5".parse().unwrap(), &headers),
|
||||||
|
"192.0.2.5".parse::<IpAddr>().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limits_repeated_token_creation_by_ip() {
|
||||||
|
let limiter = limiter(&[]);
|
||||||
|
let ip = "192.0.2.5".parse().unwrap();
|
||||||
|
assert_eq!(limiter.check(ip), Ok(()));
|
||||||
|
assert!(limiter.check(ip).is_err());
|
||||||
|
assert_eq!(limiter.check("192.0.2.6".parse().unwrap()), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caches_api_permissions_by_user_id_until_the_ttl_expires() {
|
||||||
|
let cache = ApiPermissionCache::default();
|
||||||
|
let refreshed_at = Instant::now();
|
||||||
|
let access = vec![ChannelAccess {
|
||||||
|
channel_id: 10,
|
||||||
|
reason: "test".into(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
cache.insert_at(1, access, refreshed_at);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cache
|
||||||
|
.get_at(1, refreshed_at + Duration::from_secs(299))
|
||||||
|
.unwrap()[0]
|
||||||
|
.channel_id,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.get_at(1, refreshed_at + Duration::from_secs(300))
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+670
@@ -0,0 +1,670 @@
|
|||||||
|
use super::*;
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
pub(super) async fn timezone_request(jar: CookieJar, request: Request, next: Next) -> Response {
|
||||||
|
let timezone = jar
|
||||||
|
.get("timezone")
|
||||||
|
.and_then(|cookie| cookie.value().parse::<chrono_tz::Tz>().ok());
|
||||||
|
let detect = timezone.is_none() && request.uri().path() == "/";
|
||||||
|
ACTIVE_TIMEZONE
|
||||||
|
.scope(
|
||||||
|
TimezoneContext {
|
||||||
|
timezone: timezone.unwrap_or(chrono_tz::UTC),
|
||||||
|
detect,
|
||||||
|
},
|
||||||
|
next.run(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn require_user(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
session: Session,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
match session.get::<WebUser>("user").await {
|
||||||
|
Ok(Some(mut user)) => {
|
||||||
|
if channel_access_needs_refresh(&user, request.uri().path()) {
|
||||||
|
let channel_access = match accessible_channels(&data, user.id).await {
|
||||||
|
Ok(channel_access) => channel_access,
|
||||||
|
Err(error) => {
|
||||||
|
error!("Discord permission refresh failed: {}", error);
|
||||||
|
return StatusCode::BAD_GATEWAY.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
user.channel_ids = channel_access
|
||||||
|
.iter()
|
||||||
|
.map(|access| access.channel_id)
|
||||||
|
.collect();
|
||||||
|
user.channel_access = channel_access;
|
||||||
|
user.channel_access_refreshed_at = unix_timestamp();
|
||||||
|
if let Err(error) = session.insert("user", &user).await {
|
||||||
|
error!("Could not update refreshed session permissions: {}", error);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !request_is_allowed(&data.pool, &user, request.uri().path()).await {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn unix_timestamp() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn channel_access_needs_refresh_at(user: &WebUser, path: &str, now: u64) -> bool {
|
||||||
|
let expired =
|
||||||
|
now.saturating_sub(user.channel_access_refreshed_at) >= CHANNEL_ACCESS_TTL_SECONDS;
|
||||||
|
let requested_channel_is_missing = path
|
||||||
|
.trim_matches('/')
|
||||||
|
.split('/')
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.as_slice()
|
||||||
|
.get(0..2)
|
||||||
|
.and_then(|parts| match parts {
|
||||||
|
["channels", id] => id.parse::<i64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.is_some_and(|channel_id| !user.channel_ids.contains(&channel_id));
|
||||||
|
expired || requested_channel_is_missing
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn channel_access_needs_refresh(user: &WebUser, path: &str) -> bool {
|
||||||
|
channel_access_needs_refresh_at(user, path, unix_timestamp())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn request_is_allowed(pool: &PgPool, user: &WebUser, path: &str) -> bool {
|
||||||
|
let parts = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
||||||
|
let channel_id = match parts.as_slice() {
|
||||||
|
["channels", id, ..] => id.parse::<i64>().ok(),
|
||||||
|
["messages", id] => return match id.parse::<i64>() {
|
||||||
|
Ok(id) => sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM messages
|
||||||
|
WHERE message_id = $1
|
||||||
|
AND (author_id = $2 OR channel_id = ANY($3))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
["attachments", id] => return match id.parse::<i64>() {
|
||||||
|
Ok(id) => sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM attachments a
|
||||||
|
JOIN messages m ON m.message_id = a.message_id
|
||||||
|
WHERE a.attachment_id = $1
|
||||||
|
AND (m.author_id = $2 OR m.channel_id = ANY($3))
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&user.channel_ids)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
["servers", id, ..] => return match id.parse::<i64>() {
|
||||||
|
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::<i64>() {
|
||||||
|
Ok(id) if id == user.id => true,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn login(session: Session) -> Response {
|
||||||
|
match session.get::<WebUser>("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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn privacy(session: Session) -> Response {
|
||||||
|
match session.get::<WebUser>("user").await {
|
||||||
|
Ok(user) => {
|
||||||
|
let logged_in = user.is_some();
|
||||||
|
let body = render_template(&PrivacyTemplate { logged_in });
|
||||||
|
Html(render_page("Privacy", &body, logged_in)).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn privacy_policy(session: Session) -> Response {
|
||||||
|
match session.get::<WebUser>("user").await {
|
||||||
|
Ok(user) => Html(render_page(
|
||||||
|
"Privacy policy",
|
||||||
|
&privacy_policy_html(),
|
||||||
|
user.is_some(),
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn privacy_policy_html() -> String {
|
||||||
|
const MARKDOWN: &str = include_str!("../../static/privacy-policy.md");
|
||||||
|
|
||||||
|
let options = pulldown_cmark::Options::all();
|
||||||
|
let parser = pulldown_cmark::Parser::new_ext(MARKDOWN, options);
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
pulldown_cmark::html::push_html(&mut output, parser);
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn anonymize_confirmation() -> Html<String> {
|
||||||
|
let body = render_template(&AnonymizeConfirmationTemplate);
|
||||||
|
Html(page("Confirm anonymization", &body))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn anonymize_all(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
Extension(user): Extension<WebUser>,
|
||||||
|
session: Session,
|
||||||
|
) -> Response {
|
||||||
|
let result = async {
|
||||||
|
let mut transaction = data.pool.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO discord_users (discord_id, discord_username)
|
||||||
|
VALUES (0, 'Deleted user')
|
||||||
|
ON CONFLICT (discord_id) DO UPDATE SET discord_username = EXCLUDED.discord_username",
|
||||||
|
)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO discord_user_history
|
||||||
|
(discord_id, discord_username, discord_avatar_url)
|
||||||
|
VALUES (0, 'Deleted user', NULL)
|
||||||
|
ON CONFLICT (discord_id, discord_username, discord_avatar_url)
|
||||||
|
DO UPDATE SET last_seen_at = EXCLUDED.last_seen_at",
|
||||||
|
)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO guild_users (guild_id, discord_id, first_seen_at, last_seen_at)
|
||||||
|
SELECT guild_id, 0, first_seen_at, last_seen_at
|
||||||
|
FROM guild_users
|
||||||
|
WHERE discord_id = $1
|
||||||
|
ON CONFLICT (guild_id, discord_id) DO UPDATE SET
|
||||||
|
first_seen_at = LEAST(guild_users.first_seen_at, EXCLUDED.first_seen_at),
|
||||||
|
last_seen_at = GREATEST(guild_users.last_seen_at, EXCLUDED.last_seen_at)",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE messages
|
||||||
|
SET author_id = 0, author_username = 'Deleted user'
|
||||||
|
WHERE author_id = $1",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM discord_users WHERE discord_id = $1")
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(error) = result {
|
||||||
|
error!("Could not anonymize Discord user {}: {}", user.id, error);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = session.delete().await;
|
||||||
|
Redirect::to("/privacy").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn set_theme(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<ThemeForm>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn theme_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
|
||||||
|
include_str!("../../static/theme.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn timezone_js() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
|
||||||
|
include_str!("../../static/timezone.js"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn set_timezone(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(form): Json<TimezoneForm>,
|
||||||
|
) -> Response {
|
||||||
|
if form.timezone.parse::<chrono_tz::Tz>().is_err() {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
let cookie = Cookie::build(("timezone", form.timezone))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.secure(!data.redirect_uri.starts_with("http://"))
|
||||||
|
.max_age(Duration::days(365))
|
||||||
|
.build();
|
||||||
|
(jar.add(cookie), StatusCode::NO_CONTENT).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn discord_login(State(data): State<WebData>, 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn discord_callback(
|
||||||
|
State(data): State<WebData>,
|
||||||
|
session: Session,
|
||||||
|
Query(query): Query<OAuthQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let state = session.remove::<String>("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::<OAuthToken>().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::<DiscordUser>().await else {
|
||||||
|
return oauth_error("Discord returned an invalid user response");
|
||||||
|
};
|
||||||
|
let Ok(user_id) = user.id.parse::<i64>() 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,
|
||||||
|
channel_access_refreshed_at: unix_timestamp(),
|
||||||
|
};
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn logout(session: Session) -> Redirect {
|
||||||
|
let _ = session.delete().await;
|
||||||
|
Redirect::to("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn oauth_error(message: &str) -> Response {
|
||||||
|
(StatusCode::BAD_GATEWAY, message.to_string()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn accessible_channels(
|
||||||
|
data: &WebData,
|
||||||
|
user_id: i64,
|
||||||
|
) -> Result<Vec<ChannelAccess>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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::<DiscordUser>(data, "/users/@me").await?;
|
||||||
|
let bot_id = bot.id.parse::<i64>()?;
|
||||||
|
let archived_channels = sqlx::query_scalar::<_, i64>("SELECT channel_id FROM channels;")
|
||||||
|
.fetch_all(&data.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let mut visible = Vec::new();
|
||||||
|
|
||||||
|
for (guild_id, guild_name) in guilds {
|
||||||
|
let user = discord_get_optional::<DiscordMember>(
|
||||||
|
data,
|
||||||
|
&format!("/guilds/{}/members/{}", guild_id, user_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(user) = user else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let bot = discord_get_optional::<DiscordMember>(
|
||||||
|
data,
|
||||||
|
&format!("/guilds/{}/members/{}", guild_id, bot_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(bot) = bot else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let roles =
|
||||||
|
discord_get::<Vec<DiscordRole>>(data, &format!("/guilds/{}/roles", guild_id)).await?;
|
||||||
|
let channels =
|
||||||
|
discord_get::<Vec<DiscordChannel>>(data, &format!("/guilds/{}/channels", guild_id))
|
||||||
|
.await?;
|
||||||
|
let role_names = roles
|
||||||
|
.iter()
|
||||||
|
.map(|role| Ok((role.id.parse::<i64>()?, role.name.clone())))
|
||||||
|
.collect::<Result<HashMap<_, _>, Box<dyn std::error::Error + Send + Sync>>>()?;
|
||||||
|
let role_permissions = roles
|
||||||
|
.iter()
|
||||||
|
.map(|role| Ok((role.id.parse::<i64>()?, role.permissions.parse::<u64>()?)))
|
||||||
|
.collect::<Result<HashMap<_, _>, Box<dyn std::error::Error + Send + Sync>>>()?;
|
||||||
|
let user_roles = user
|
||||||
|
.roles
|
||||||
|
.iter()
|
||||||
|
.map(|role| role.parse::<i64>())
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let bot_roles = bot
|
||||||
|
.roles
|
||||||
|
.iter()
|
||||||
|
.map(|role| role.parse::<i64>())
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
for channel in channels {
|
||||||
|
let channel_id = channel.id.parse::<i64>()?;
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn discord_get<T: serde::de::DeserializeOwned>(
|
||||||
|
data: &WebData,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn discord_get_optional<T: serde::de::DeserializeOwned>(
|
||||||
|
data: &WebData,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<T>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
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?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn can_view_channel(
|
||||||
|
guild_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
member_roles: &[i64],
|
||||||
|
roles: &HashMap<i64, u64>,
|
||||||
|
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::<i64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if overwrite.kind == 0 && member_roles.contains(&role_id) {
|
||||||
|
allow |= overwrite.allow.parse::<u64>().unwrap_or_default();
|
||||||
|
deny |= overwrite.deny.parse::<u64>().unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissions &= !deny;
|
||||||
|
permissions |= allow;
|
||||||
|
apply_overwrite(&mut permissions, overwrites, user_id, 1);
|
||||||
|
permissions & VIEW_CHANNEL != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn view_channel_reason(
|
||||||
|
guild_id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
member_roles: &[i64],
|
||||||
|
roles: &HashMap<i64, u64>,
|
||||||
|
role_names: &HashMap<i64, String>,
|
||||||
|
overwrites: &[DiscordOverwrite],
|
||||||
|
guild_name: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
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::<i64>() == Ok(user_id)
|
||||||
|
&& overwrite.allow.parse::<u64>().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::<i64>() == Ok(*role_id)
|
||||||
|
&& overwrite.allow.parse::<u64>().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::<i64>() == Ok(guild_id)
|
||||||
|
&& overwrite.allow.parse::<u64>().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))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_overwrite(
|
||||||
|
permissions: &mut u64,
|
||||||
|
overwrites: &[DiscordOverwrite],
|
||||||
|
id: i64,
|
||||||
|
kind: u8,
|
||||||
|
) {
|
||||||
|
let overwrite = overwrites
|
||||||
|
.iter()
|
||||||
|
.find(|overwrite| overwrite.kind == kind && overwrite.id.parse::<i64>() == Ok(id));
|
||||||
|
if let Some(overwrite) = overwrite {
|
||||||
|
*permissions &= !overwrite.deny.parse::<u64>().unwrap_or_default();
|
||||||
|
*permissions |= overwrite.allow.parse::<u64>().unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1081
File diff suppressed because it is too large.
Load diff
+1264
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
|||||||
|
<h2>Confirm anonymization</h2>
|
||||||
|
<p>Are you certain you want to proceed?</p>
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
<form method="post" action="/privacy/anonymize">
|
||||||
|
<button type="submit">Confirm</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/privacy">Cancel</a></p>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<form method="post" action="$${{action}}">
|
<form method="post" action="{{action}}">
|
||||||
<input type="hidden" name="search" value="$${{search}}">
|
<input type="hidden" name="search" value="{{search}}">
|
||||||
$${{additional_fields}} <label>Page <input type="number" name="page" min="1" max="$${{total_pages}}" required></label>
|
{% if let Some(search_by) = search_by %} <input type="hidden" name="search_by" value="{{search_by}}">
|
||||||
|
{% endif %} <label>Page <input type="number" name="page" min="1" max="{{total_pages}}" required></label>
|
||||||
<button type="submit">Go</button>
|
<button type="submit">Go</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1 +0,0 @@
|
|||||||
— $${{value}}
|
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="/attachments/$${{attachment_id}}?version=$${{message_version}}" target="_blank">View $${{filename}}</a> — $${{size}} bytes$${{content_type}}$${{description}}</li>
|
<li><a href="/attachments/{{attachment_id}}?version={{message_version}}" target="_blank">View {{filename}}</a> - {{size}} bytes{% if let Some(value) = content_type %} - {{value}}{% endif %}{% if let Some(value) = description %} - {{value}}{% endif %}</li>
|
||||||
+13
-12
@@ -1,22 +1,23 @@
|
|||||||
<h2>$${{channel_name}}</h2>
|
<h2>{{channel_name}}</h2>
|
||||||
|
<p>You can see this channel because {{access_reason}}.</p>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Channel ID</dt><dd>$${{channel_id}}</dd>
|
<dt>Channel ID</dt><dd>{{channel_id}}</dd>
|
||||||
<dt>Server</dt><dd><a href="/servers/$${{server_id}}">$${{server_name}}</a></dd>
|
<dt>Server</dt><dd><a href="/servers/{{server_id}}">{{server_name}}</a></dd>
|
||||||
<dt>Messages</dt><dd>$${{message_count}}</dd>
|
<dt>Messages</dt><dd>{{message_count}}</dd>
|
||||||
<dt>Observed users</dt><dd>$${{user_count}}</dd>
|
<dt>Observed users</dt><dd>{{user_count}}</dd>
|
||||||
<dt>Message versions</dt><dd>$${{version_count}}</dd>
|
<dt>Message versions</dt><dd>{{version_count}}</dd>
|
||||||
<dt>Archived data</dt><dd>$${{total_storage}}</dd>
|
<dt>Archived data</dt><dd>{{total_storage}}</dd>
|
||||||
<dt>Message content</dt><dd>$${{message_storage}}</dd>
|
<dt>Message content</dt><dd>{{message_storage}}</dd>
|
||||||
<dt>Attachments</dt><dd>$${{attachment_storage}}</dd>
|
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form method="get" action="/channels/$${{channel_id}}/messages">
|
<form method="get" action="/channels/{{channel_id}}/messages">
|
||||||
<button type="submit">View messages in this channel</button>
|
<button type="submit">View messages in this channel</button>
|
||||||
</form>
|
</form>
|
||||||
<h3>Known names</h3>
|
<h3>Known names</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{names}}
|
{{names|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Observed users</h3>
|
<h3>Observed users</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{users}}
|
{{users|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<li><a href="/channels/$${{channel_id}}"><strong>$${{channel_name}}</strong> ($${{channel_id}})</a> in <a href="/servers/$${{server_id}}">$${{server_name}}</a></li>
|
<li><a href="/channels/{{channel_id}}"><strong>{{channel_name}}</strong> ({{channel_id}})</a> in <a href="/servers/{{server_id}}">{{server_name}}</a></li>
|
||||||
@@ -1 +1 @@
|
|||||||
<strong>$${{page_number}}</strong>
|
<strong>{{page_number}}</strong>
|
||||||
@@ -1 +0,0 @@
|
|||||||
<pre>$${{description}}</pre>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<strong>$${{title}}</strong>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<a href="$${{url}}">$${{url}}</a>
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<li>$${{title}}$${{description}}$${{url}}</li>
|
<li>{% if let Some(value) = title %}<strong>{{value}}</strong>{% endif %}{% if let Some(value) = description %}<pre>{{value}}</pre>{% endif %}{% if let Some(value) = url %}<a href="{{value}}">{{value}}</a>{% endif %}</li>
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
<h2>Error</h2>
|
<h2>Error</h2>
|
||||||
<p>$${{message}}</p>
|
<p>{{message}}</p>
|
||||||
+6
-4
@@ -1,8 +1,10 @@
|
|||||||
<h2>Archive</h2>
|
<h2>Archive</h2>
|
||||||
<p>Archived $${{message_count}} messages from $${{user_count}} users across $${{channel_count}} channels on $${{server_count}} servers.</p>
|
<p>Archived {{message_count}} messages from {{user_count}} users across {{channel_count}} channels on {{server_count}} servers.</p>
|
||||||
<h3>Storage usage</h3>
|
<h3>Storage usage</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Total archive</dt><dd>$${{total_storage}}</dd>
|
<dt>Archived data</dt><dd>{{total_storage}}</dd>
|
||||||
<dt>Messages and metadata</dt><dd>$${{message_storage}}</dd>
|
<dt>Message content</dt><dd>{{message_storage}}</dd>
|
||||||
<dt>Attachments</dt><dd>$${{attachment_storage}}</dd>
|
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<p>If you cannot see any messages, try and send a message on a server the bot is in.</p>
|
||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
<h2>$${{title}}</h2>
|
<h2>{{title}}</h2>
|
||||||
$${{search_form}}
|
{{search_form|safe}}
|
||||||
<p>$${{item_count}} $${{item_name}} found.</p>
|
<p>{{item_count}} {{item_name}} found.</p>
|
||||||
<ul>
|
<ul>
|
||||||
$${{items}}
|
{{items|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
$${{pagination}}
|
{{pagination|safe}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<h2>Log in</h2>
|
||||||
|
<p>It is required to log in with Discord to view the archive.</p>
|
||||||
|
<p>The login happens on the official Discord website. This site does not have access to your email or password.</p>
|
||||||
|
<form method="get" action="/login/discord">
|
||||||
|
<button type="submit">Log in with Discord</button>
|
||||||
|
</form>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="/messages/$${{message_id}}">Message $${{message_id}}</a> by <a href="/users/$${{author_id}}"><strong>$${{author}}</strong></a> in <a href="/servers/$${{server_id}}">$${{server}}</a> / <a href="/channels/$${{channel_id}}">$${{channel}}</a> at $${{timestamp}} — $${{attachment_count}} attachments, $${{embed_count}} embeds</li>
|
<li><a href="/messages/{{message_id}}">Message {{message_id}}</a> by <a href="/users/{{author_id}}"><strong>{{author}}</strong></a> in <a href="/servers/{{server_id}}">{{server}}</a> / <a href="/channels/{{channel_id}}">{{channel}}</a> at {{timestamp}} - {{attachment_count}} attachments, {{embed_count}} embeds</li>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<h2>$${{title}}</h2>
|
<h2>{{title}}</h2>
|
||||||
$${{search_form}}
|
{{search_form|safe}}
|
||||||
<p>$${{item_count}} archived messages found.</p>
|
<p>{{item_count}} archived messages found.</p>
|
||||||
<ol>
|
<ol>
|
||||||
$${{items}}
|
{{items|safe}}
|
||||||
</ol>
|
</ol>
|
||||||
$${{pagination}}
|
{{pagination|safe}}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<form method="post" action="$${{action}}">
|
<form method="post" action="{{action}}">
|
||||||
<label>Search messages: <input type="search" name="search" value="$${{search}}"></label>
|
<label>Search messages: <input type="search" name="search" value="{{search}}"></label>
|
||||||
<label>Search by:
|
<label>Search by:
|
||||||
<select name="search_by">
|
<select name="search_by">
|
||||||
<option value="content"$${{content_selected}}>Content</option>
|
<option value="content"{{content_selected}}>Content</option>
|
||||||
<option value="timestamp"$${{timestamp_selected}}>Timestamp</option>
|
<option value="timestamp"{{timestamp_selected}}>Timestamp</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<form method="get" action="/messages/$${{message_id}}">
|
<form method="get" action="/messages/{{message_id}}">
|
||||||
<button type="submit" name="version" value="$${{version}}">$${{label}}</button>
|
<button type="submit" name="version" value="{{version}}">{{label}}</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<nav aria-label="Message versions">
|
<nav aria-label="Message versions">
|
||||||
<p>Version $${{current_version}} of $${{version_count}} — archived $${{archived_at}}</p>
|
<p>Version {{current_version}} of {{version_count}} - archived {{archived_at}}</p>
|
||||||
<p>
|
<p>
|
||||||
$${{previous_version}}$${{next_version}} </p>
|
{{previous_version|safe}}{{next_version|safe}} </p>
|
||||||
<form method="get" action="/messages/$${{message_id}}">
|
<form method="get" action="/messages/{{message_id}}">
|
||||||
<label>Version <input type="number" name="version" min="1" max="$${{version_count}}" value="$${{current_version}}" required></label>
|
<label>Version <input type="number" name="version" min="1" max="{{version_count}}" value="{{current_version}}" required></label>
|
||||||
<button type="submit">View version</button>
|
<button type="submit">View version</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
+10
-9
@@ -1,18 +1,19 @@
|
|||||||
<h2>Message $${{message_id}}</h2>
|
<h2>Message {{message_id}}</h2>
|
||||||
$${{version_navigation}}
|
<p>You can see this message because {{access_reason}}.</p>
|
||||||
|
{{version_navigation|safe}}
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Author</dt><dd><a href="/users/$${{author_id}}">$${{author}} ($${{author_id}})</a></dd>
|
<dt>Author</dt><dd><a href="/users/{{author_id}}">{{author}} ({{author_id}})</a></dd>
|
||||||
<dt>Server</dt><dd><a href="/servers/$${{server_id}}">$${{server}}</a></dd>
|
<dt>Server</dt><dd><a href="/servers/{{server_id}}">{{server}}</a></dd>
|
||||||
<dt>Channel</dt><dd><a href="/channels/$${{channel_id}}">$${{channel}}</a></dd>
|
<dt>Channel</dt><dd><a href="/channels/{{channel_id}}">{{channel}}</a></dd>
|
||||||
<dt>Timestamp</dt><dd>$${{timestamp}}</dd>
|
<dt>Timestamp</dt><dd>{{timestamp}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<h3>Content</h3>
|
<h3>Content</h3>
|
||||||
<pre>$${{content}}</pre>
|
<pre>{{content}}</pre>
|
||||||
<h3>Attachments</h3>
|
<h3>Attachments</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{attachments}}
|
{{attachments|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Embeds</h3>
|
<h3>Embeds</h3>
|
||||||
<ol>
|
<ol>
|
||||||
$${{embeds}}
|
{{embeds|safe}}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<form method="post" action="$${{action}}">
|
<form method="post" action="{{action}}">
|
||||||
<input type="hidden" name="search" value="$${{search}}">
|
<input type="hidden" name="search" value="{{search}}">
|
||||||
$${{additional_fields}} <button type="submit" name="page" value="$${{page_number}}">$${{label}}</button>
|
{% if let Some(search_by) = search_by %} <input type="hidden" name="search_by" value="{{search_by}}">
|
||||||
|
{% endif %} <button type="submit" name="page" value="{{page_number}}">{{label}}</button>
|
||||||
</form>
|
</form>
|
||||||
+29
-9
@@ -1,23 +1,43 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html class="theme-{{theme}}" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>$${{title}} — TG Archive</title>
|
<title>{{ title }} - TG Archive</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
{% if detect_timezone %} <script src="/timezone.js" defer></script>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="site-header">
|
||||||
<h1>TG Archive</h1>
|
<strong class="site-brand">TG Archive</strong>
|
||||||
<nav>
|
{% if logged_in %} <nav class="site-navigation">
|
||||||
<a href="/">Home</a> |
|
<a href="/">Home</a> |
|
||||||
<a href="/servers">Servers</a> |
|
<a href="/servers">Servers</a> |
|
||||||
<a href="/channels">Channels</a> |
|
<a href="/channels">Channels</a> |
|
||||||
<a href="/users">Users</a> |
|
<a href="/users">Users</a> |
|
||||||
<a href="/messages">Messages</a>
|
<a href="/messages">Messages</a> |
|
||||||
</nav>
|
<form method="post" action="/logout"><button type="submit">Log out</button></form>
|
||||||
|
</nav>{% endif %}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="page-content">
|
||||||
$${{body}}
|
{{ body|safe }}
|
||||||
</main>
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p class="footer-legal">{% if logged_in %}<a href="/privacy">Privacy</a><br>{% endif %}
|
||||||
|
Source code available <a href="https://git.ewenlau.net/ewenlau/tg-archive">here</a>.<br>
|
||||||
|
Copyright © 2026 Ewi and contributors<br>
|
||||||
|
This content is licensed under GPL-3.0.</p>
|
||||||
|
<form class="footer-theme" method="post" action="/theme">
|
||||||
|
<label>Theme
|
||||||
|
<select name="theme">
|
||||||
|
<option value="white"{% if theme == "white" %} selected{% endif %}>White</option>
|
||||||
|
<option value="black"{% if theme == "black" %} selected{% endif %}>Black</option>
|
||||||
|
<option value="oled"{% if theme == "oled" %} selected{% endif %}>OLED</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
</form>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<nav aria-label="Pages">
|
<nav aria-label="Pages">
|
||||||
<p>
|
<p>
|
||||||
$${{first_pages}}
|
{{first_pages|safe}}
|
||||||
</p>
|
</p>
|
||||||
$${{arbitrary_page}}
|
{{arbitrary_page|safe}}
|
||||||
<p>
|
<p>
|
||||||
$${{final_pages}}
|
{{final_pages|safe}}
|
||||||
</p>
|
</p>
|
||||||
<p>Page $${{current_page}} of $${{total_pages}}</p>
|
<p>Page {{current_page}} of {{total_pages}}</p>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Privacy Policy
|
||||||
|
|
||||||
|
Last updated: 31 August 2026
|
||||||
|
|
||||||
|
This Privacy Policy explains how TG Archive ("the Archive") collects, uses, stores, and protects information obtained through Discord and through the Archive's web interface.
|
||||||
|
|
||||||
|
The Archive exists to maintain a historical archive of the Discord server in which the Archive bot operates and to make that archive available to authorized members of that server.
|
||||||
|
|
||||||
|
## 1. Data Controller
|
||||||
|
|
||||||
|
The Archive is operated by Elias Wendland.
|
||||||
|
|
||||||
|
For privacy-related questions or requests, contact:
|
||||||
|
|
||||||
|
tg-archive-privacy@mail.ewenlau.net
|
||||||
|
|
||||||
|
## 2. Information We Collect
|
||||||
|
|
||||||
|
The Archive may collect and store information made available through the Discord API, including:
|
||||||
|
|
||||||
|
- Discord user IDs, usernames, display names, avatars, and other relevant user information;
|
||||||
|
- message content;
|
||||||
|
- message IDs and timestamps;
|
||||||
|
- message edits, replies, attachments, embeds, reactions, and other message-related information where applicable;
|
||||||
|
- server, channel, thread, role, and permission information necessary to organize the archive and control access to it; and
|
||||||
|
- other Discord API data necessary for the operation, security, and maintenance of the Archive.
|
||||||
|
|
||||||
|
When you use the Archive's web interface, the Archive may also process information necessary to authenticate you through Discord OAuth2, maintain your session, enforce access controls, and protect the service from abuse.
|
||||||
|
|
||||||
|
The Archive is not intended to collect information unrelated to its archival, authentication, access-control, security, or maintenance functions.
|
||||||
|
|
||||||
|
## 3. Why We Process This Data
|
||||||
|
|
||||||
|
Discord data is processed for the purpose of maintaining a historical archive of the server and making archived material available to authorized server members.
|
||||||
|
|
||||||
|
Additional processing may be performed where necessary to:
|
||||||
|
|
||||||
|
- authenticate users;
|
||||||
|
- determine which archived channels a user is currently permitted to access;
|
||||||
|
- operate and secure the service;
|
||||||
|
- investigate technical problems or abuse; and
|
||||||
|
- comply with applicable legal obligations and Discord's requirements.
|
||||||
|
|
||||||
|
## 4. Legal Basis
|
||||||
|
|
||||||
|
Where the General Data Protection Regulation ("GDPR") applies, the Archive primarily processes personal data on the basis of legitimate interests under Article 6(1)(f) GDPR.
|
||||||
|
|
||||||
|
The legitimate interest pursued is the preservation and provision of a historical archive of the server's discussions and activities. The Archive limits access to archived material according to users' current Discord permissions and implements measures intended to reduce unnecessary impacts on users' privacy.
|
||||||
|
|
||||||
|
Where processing is necessary to comply with a legal obligation, Article 6(1)(c) GDPR may also apply.
|
||||||
|
|
||||||
|
## 5. Access to Archived Data
|
||||||
|
|
||||||
|
The Archive is not intended to make private Discord content publicly accessible.
|
||||||
|
|
||||||
|
Access to the web interface requires authentication through Discord OAuth2. Before providing access to archived channel content, the Archive checks whether the authenticated Discord user is currently authorized to access the corresponding channel.
|
||||||
|
|
||||||
|
Losing access to a channel on Discord therefore also results in losing access to that channel through the Archive, although personal content is always accessible to the user who created it.
|
||||||
|
|
||||||
|
Archive administrators may access stored data where reasonably necessary to operate, secure, maintain, or troubleshoot the service or to respond to privacy and legal requests.
|
||||||
|
|
||||||
|
## 6. Data Retention
|
||||||
|
|
||||||
|
Because the purpose of the service is historical archival, archived messages and associated metadata may be retained for an extended period while the Archive continues to operate and the information remains necessary for its archival purpose.
|
||||||
|
|
||||||
|
Information that is no longer necessary for the operation or stated purposes of the Archive will be deleted or anonymized where appropriate.
|
||||||
|
|
||||||
|
Data may also be deleted when required by applicable law, Discord's requirements, or a valid data-subject request.
|
||||||
|
|
||||||
|
If operation of the Archive is permanently discontinued, stored Discord API data will be handled in accordance with Discord's applicable requirements and applicable law.
|
||||||
|
|
||||||
|
## 7. Anonymization
|
||||||
|
|
||||||
|
Authenticated users are provided with a self-service mechanism to de-identify previously archived data associated with their Discord account.
|
||||||
|
|
||||||
|
When this function is used, identifying account information and the association between the user's Discord account and previously archived messages are removed. Message content and non-identifying message metadata, such as timestamps, may remain in the Archive.
|
||||||
|
|
||||||
|
Because message content itself may contain information capable of identifying its author, use of this feature should not necessarily be understood as complete anonymization for every purpose under applicable data protection law.
|
||||||
|
|
||||||
|
Anonymization affects only information already stored at the time the action is performed. If the user subsequently participates in the Discord server, newly generated information may be archived and associated with their Discord account.
|
||||||
|
|
||||||
|
This feature is separate from the right to request deletion of personal data.
|
||||||
|
|
||||||
|
## 8. Your Data Protection Rights
|
||||||
|
|
||||||
|
Depending on applicable law and the circumstances of the processing, you may have rights including the right to:
|
||||||
|
|
||||||
|
- request access to personal data concerning you;
|
||||||
|
- request correction of inaccurate personal data;
|
||||||
|
- request erasure of personal data;
|
||||||
|
- request restriction of processing;
|
||||||
|
- object to processing based on legitimate interests; and
|
||||||
|
- receive certain personal data in a portable format where the legal requirements for data portability apply.
|
||||||
|
|
||||||
|
To exercise these rights, contact:
|
||||||
|
|
||||||
|
tg-archive-privacy@mail.ewenlau.net
|
||||||
|
|
||||||
|
The Archive may request information reasonably necessary to verify that you control the Discord account concerned before fulfilling a request.
|
||||||
|
|
||||||
|
Requests made under the GDPR will generally be answered within one month of receipt. This period may be extended where permitted by law, in which case you will be informed as required by the GDPR.
|
||||||
|
|
||||||
|
You also have the right to lodge a complaint with a competent data protection supervisory authority.
|
||||||
|
|
||||||
|
## 9. Deletion Requests
|
||||||
|
|
||||||
|
You may request deletion of personal data associated with your Discord account by contacting tg-archive-privacy@mail.ewenlau.net.
|
||||||
|
|
||||||
|
Deletion requests will be handled in accordance with applicable law and Discord's requirements. Where data must be deleted, it will be removed from the Archive rather than merely hidden from your account.
|
||||||
|
|
||||||
|
Some minimal non-personal structural information may be retained where doing so does not identify or remain associated with you and is necessary to preserve the structure of the Archive.
|
||||||
|
|
||||||
|
## 10. Data Security
|
||||||
|
|
||||||
|
Reasonable technical and organizational measures are used to protect stored information against unauthorized access, disclosure, alteration, or destruction.
|
||||||
|
|
||||||
|
Discord API data stored by the Archive is encrypted at rest. Connections to the Archive's web interface are protected using HTTPS/TLS.
|
||||||
|
|
||||||
|
Access to stored data and administrative systems is restricted to persons and systems requiring access for operation of the Archive.
|
||||||
|
|
||||||
|
No method of electronic storage or transmission can provide an absolute guarantee of security.
|
||||||
|
|
||||||
|
## 11. Service Providers and Data Sharing
|
||||||
|
|
||||||
|
Personal data is not sold.
|
||||||
|
|
||||||
|
Data may be processed by infrastructure or service providers where necessary to host, secure, or operate the Archive. These providers process information only as necessary to provide their respective services.
|
||||||
|
|
||||||
|
Current relevant service providers include:
|
||||||
|
|
||||||
|
- Discord, as the platform from which archived data originates and as an authentication provider;
|
||||||
|
- Cloudflare, which may process certain network traffic for content delivery, security, and DDoS protection where its services are used;
|
||||||
|
- Proton, which provides email services and may therefore process personal data contained in emails sent to or from the Archive, including privacy requests.
|
||||||
|
|
||||||
|
Data may also be disclosed where required by law or where reasonably necessary to protect the security and integrity of the service.
|
||||||
|
|
||||||
|
## 12. International Data Transfers
|
||||||
|
|
||||||
|
The Archive's primary application, database, backups, and logging infrastructure are self-hosted and operated directly by the Archive operator in France.
|
||||||
|
|
||||||
|
Some third-party services used by the Archive may process personal data outside the EEA. These include Discord and, where applicable, Cloudflare. Proton provides email services for the Archive.
|
||||||
|
|
||||||
|
Where personal data is transferred outside the EEA, such transfers are handled in accordance with applicable data protection law and the transfer mechanisms implemented by the relevant service providers.
|
||||||
|
|
||||||
|
## 13. Automated Decision-Making and Profiling
|
||||||
|
|
||||||
|
The Archive does not use archived Discord messages to make decisions producing legal or similarly significant effects concerning users.
|
||||||
|
|
||||||
|
The Archive does not use archived message content to create behavioral profiles of Discord users.
|
||||||
|
|
||||||
|
## 14. Changes to This Policy
|
||||||
|
|
||||||
|
This Privacy Policy may be updated when the Archive's functionality, data-processing practices, legal obligations, or Discord's requirements change.
|
||||||
|
|
||||||
|
Material changes will be communicated where required by applicable law.
|
||||||
|
|
||||||
|
The date at the top of this policy indicates when it was last updated.
|
||||||
|
|
||||||
|
## 15. Contact
|
||||||
|
|
||||||
|
For questions about this Privacy Policy or requests concerning your personal data, contact:
|
||||||
|
|
||||||
|
tg-archive-privacy@mail.ewenlau.net
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This privacy policy was drafted with assistance from ChatGPT and subsequently reviewed and adopted by the operator. The operator remains responsible for the accuracy of this policy and for the Archive's data-processing practices.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% if logged_in %}
|
||||||
|
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
<p>This app collects substantial Discord API data, including messages, user information, and server data.</p>
|
||||||
|
<p>Per Discord's terms of service and the GDPR, you are entitled to access, update, or delete your personal information.</p>
|
||||||
|
<p>This button allows you to anonymize all your data in the app. Message content and metadata (such as timestamps) will be kept, but your association to them will be removed.</p>
|
||||||
|
<p>More precisely, all your messages will be linked to a generic, anonymous user ID, common for all deleted users, and your user account entry will be removed. Keep in mind that this only applies to past data; future data will not be affected until you choose to anonymize it.</p>
|
||||||
|
|
||||||
|
<form method="get" action="/privacy/anonymize">
|
||||||
|
<button type="submit">Anonymize all</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you need further privacy assistance, such as full access or deletion of your data, please send an email to <a href="mailto:tg-archive-privacy@mail.ewenlau.net">tg-archive-privacy@mail.ewenlau.net</a>. Per GDPR regulation, you will receive a response within one month. If you do not receive a response, please check your spam folder or contact me again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>For more information about what data is collected, and how it is used, please refer to the privacy policy.</p>
|
||||||
|
<p>The full privacy policy can be found <a href="/privacy-policy">here</a>.</p>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<form method="post" action="$${{action}}">
|
<form method="post" action="{{action}}">
|
||||||
<label>$${{label}}: <input type="search" name="search" value="$${{search}}"></label>
|
<label>{{label}}: <input type="search" name="search" value="{{search}}"></label>
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1 +1 @@
|
|||||||
— <a href="$${{icon_url}}">icon</a>
|
- <a href="{{icon_url}}">icon</a>
|
||||||
+14
-14
@@ -1,31 +1,31 @@
|
|||||||
<h2>$${{server_name}}</h2>
|
<h2>{{server_name}}</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Server ID</dt><dd>$${{server_id}}</dd>
|
<dt>Server ID</dt><dd>{{server_id}}</dd>
|
||||||
<dt>Messages</dt><dd>$${{message_count}}</dd>
|
<dt>Messages</dt><dd>{{message_count}}</dd>
|
||||||
<dt>Archived users</dt><dd>$${{user_count}}</dd>
|
<dt>Archived users</dt><dd>{{user_count}}</dd>
|
||||||
<dt>Archived channels</dt><dd>$${{channel_count}}</dd>
|
<dt>Archived channels</dt><dd>{{channel_count}}</dd>
|
||||||
<dt>Message versions</dt><dd>$${{version_count}}</dd>
|
<dt>Message versions</dt><dd>{{version_count}}</dd>
|
||||||
<dt>Archived data</dt><dd>$${{total_storage}}</dd>
|
<dt>Archived data</dt><dd>{{total_storage}}</dd>
|
||||||
<dt>Message content</dt><dd>$${{message_storage}}</dd>
|
<dt>Message content</dt><dd>{{message_storage}}</dd>
|
||||||
<dt>Attachments</dt><dd>$${{attachment_storage}}</dd>
|
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form method="get" action="/servers/$${{server_id}}/messages">
|
<form method="get" action="/servers/{{server_id}}/messages">
|
||||||
<button type="submit">View messages in this server</button>
|
<button type="submit">View messages in this server</button>
|
||||||
</form>
|
</form>
|
||||||
<h3>Known names</h3>
|
<h3>Known names</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{names}}
|
{{names|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Known icons</h3>
|
<h3>Known icons</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{icons}}
|
{{icons|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Channels</h3>
|
<h3>Channels</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{channels}}
|
{{channels|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Observed users</h3>
|
<h3>Observed users</h3>
|
||||||
<p>Users are included after one of their messages is archived in this server.</p>
|
<p>Users are included after one of their messages is archived in this server.</p>
|
||||||
<ul>
|
<ul>
|
||||||
$${{users}}
|
{{users|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<li><a href="/servers/$${{guild_id}}"><strong>$${{guild_name}}</strong> ($${{guild_id}})</a>$${{icon}}</li>
|
<li><a href="/servers/{{guild_id}}"><strong>{{guild_name}}</strong> ({{guild_id}})</a>{{icon|safe}}</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="$${{avatar_url}}"><img src="$${{avatar_url}}" alt="Archived user avatar" width="64" height="64"></a> — first seen $${{first_seen}}, last seen $${{last_seen}}</li>
|
<li><a href="{{avatar_url}}"><img src="{{avatar_url}}" alt="Archived user avatar" width="64" height="64"></a> - first seen {{first_seen}}, last seen {{last_seen}}</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="/channels/$${{channel_id}}"><strong>$${{channel_name}}</strong></a> — $${{message_count}} messages</li>
|
<li><a href="/channels/{{channel_id}}"><strong>{{channel_name}}</strong></a> - {{message_count}} messages</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="$${{icon_url}}"><img src="$${{icon_url}}" alt="Archived server icon" width="64" height="64"></a> — first seen $${{first_seen}}, last seen $${{last_seen}}</li>
|
<li><a href="{{icon_url}}"><img src="{{icon_url}}" alt="Archived server icon" width="64" height="64"></a> - first seen {{first_seen}}, last seen {{last_seen}}</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><strong>$${{name}}</strong> — first seen $${{first_seen}}, last seen $${{last_seen}}</li>
|
<li><strong>{{name}}</strong> - first seen {{first_seen}}, last seen {{last_seen}}</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="/servers/$${{server_id}}"><strong>$${{server_name}}</strong></a> — $${{message_count}} messages</li>
|
<li><a href="/servers/{{server_id}}"><strong>{{server_name}}</strong></a> - {{message_count}} messages</li>
|
||||||
@@ -1 +1 @@
|
|||||||
<li><a href="/users/$${{user_id}}"><strong>$${{username}}</strong></a> — $${{message_count}} messages</li>
|
<li><a href="/users/{{user_id}}"><strong>{{username}}</strong></a> - {{message_count}} messages</li>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(() => {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
if (!timezone) return;
|
||||||
|
fetch("/timezone", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({timezone}),
|
||||||
|
}).then(response => {
|
||||||
|
if (response.ok) window.location.reload();
|
||||||
|
});
|
||||||
|
})();
|
||||||
+15
-14
@@ -1,30 +1,31 @@
|
|||||||
<h2>$${{username}}</h2>
|
<h2>{{username}}</h2>
|
||||||
|
<p>You can see this user because they have messages in a channel where {{access_reason}}.</p>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>User ID</dt><dd>$${{user_id}}</dd>
|
<dt>User ID</dt><dd>{{user_id}}</dd>
|
||||||
<dt>Messages</dt><dd>$${{message_count}}</dd>
|
<dt>Messages</dt><dd>{{message_count}}</dd>
|
||||||
<dt>Servers</dt><dd>$${{server_count}}</dd>
|
<dt>Servers</dt><dd>{{server_count}}</dd>
|
||||||
<dt>Channels</dt><dd>$${{channel_count}}</dd>
|
<dt>Channels</dt><dd>{{channel_count}}</dd>
|
||||||
<dt>Message versions</dt><dd>$${{version_count}}</dd>
|
<dt>Message versions</dt><dd>{{version_count}}</dd>
|
||||||
<dt>Archived data</dt><dd>$${{total_storage}}</dd>
|
<dt>Archived data</dt><dd>{{total_storage}}</dd>
|
||||||
<dt>Message content</dt><dd>$${{message_storage}}</dd>
|
<dt>Message content</dt><dd>{{message_storage}}</dd>
|
||||||
<dt>Attachments</dt><dd>$${{attachment_storage}}</dd>
|
<dt>Attachments</dt><dd>{{attachment_storage}}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<form method="get" action="/users/$${{user_id}}/messages">
|
<form method="get" action="/users/{{user_id}}/messages">
|
||||||
<button type="submit">View messages by this user</button>
|
<button type="submit">View messages by this user</button>
|
||||||
</form>
|
</form>
|
||||||
<h3>Known usernames</h3>
|
<h3>Known usernames</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{names}}
|
{{names|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Known avatars</h3>
|
<h3>Known avatars</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{avatars}}
|
{{avatars|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Servers</h3>
|
<h3>Servers</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{servers}}
|
{{servers|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Channels</h3>
|
<h3>Channels</h3>
|
||||||
<ul>
|
<ul>
|
||||||
$${{channels}}
|
{{channels|safe}}
|
||||||
</ul>
|
</ul>
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<li><a href="/users/$${{discord_id}}"><strong>$${{discord_username}}</strong> ($${{discord_id}})</a></li>
|
<li><a href="/users/{{discord_id}}"><strong>{{discord_username}}</strong> ({{discord_id}})</a></li>
|
||||||
Reference in new issue
Block a user