···77# Dev Mode Configuration
88DEV_MODE="false" # Enable dev mode for testing with dummy data. Access via ?dev=true query parameter when enabled.
991010-1010+# Custom Emojis
1111+# Directory to read/write custom emoji image files at runtime.
1212+# For local dev, keep under the repo:
1313+EMOJI_DIR="static/emojis"
···2525# navigate to http://127.0.0.1:8080
2626```
27272828+### custom emojis (no redeploys)
2929+3030+Emojis are now served from a runtime directory configured by `EMOJI_DIR` (defaults to `static/emojis` locally; set to `/data/emojis` on Fly.io). On startup, if the runtime emoji directory is empty, it will be seeded from the bundled `static/emojis`.
3131+3232+- Local dev: add image files to `static/emojis/` (or set `EMOJI_DIR` in `.env`).
3333+- Production (Fly.io): upload files directly into the mounted volume at `/data/emojis` — no rebuild or redeploy needed.
3434+3535+Examples with Fly CLI:
3636+3737+```bash
3838+# Open an SSH console to the machine
3939+fly ssh console -a zzstoatzz-status
4040+4141+# Inside the VM, copy or fetch files into /data/emojis
4242+mkdir -p /data/emojis
4343+curl -L -o /data/emojis/my_new_emoji.png https://example.com/my_new_emoji.png
4444+```
4545+4646+Or from your machine using SFTP:
4747+4848+```bash
4949+fly ssh sftp -a zzstoatzz-status
5050+sftp> put ./static/emojis/my_new_emoji.png /data/emojis/
5151+```
5252+5353+The app serves them at `/emojis/<filename>` and lists them via `/api/custom-emojis`.
5454+5555+### admin upload endpoint
5656+5757+When logged in as the admin DID, you can upload PNG or GIF emojis without SSH via a simple endpoint:
5858+5959+- Endpoint: `POST /admin/upload-emoji`
6060+- Auth: session-based; only the admin DID is allowed
6161+- Form fields (multipart/form-data):
6262+ - `file`: the image file (PNG or GIF), max 5MB
6363+ - `name` (optional): base filename (letters, numbers, `-`, `_`) without extension
6464+6565+Example with curl:
6666+6767+```bash
6868+curl -i -X POST \
6969+ -F "file=@./static/emojis/sample.png" \
7070+ -F "name=my_sample" \
7171+ http://localhost:8080/admin/upload-emoji
7272+```
7373+7474+Response will include the public URL (e.g., `/emojis/my_sample.png`).
7575+2876### available commands
29773078we use [just](https://github.com/casey/just) for common tasks:
···4391- [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium))
4492- [sqlite](https://www.sqlite.org/) for local storage
4593- [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption
4646-- [fly.io](https://fly.io/) for hosting9494+- [fly.io](https://fly.io/) for hosting
···2828 // Emoji API routes
2929 .service(status::get_frequent_emojis)
3030 .service(status::get_custom_emojis)
3131+ .service(status::upload_emoji)
3132 .service(status::get_following)
3233 // Status management routes
3334 .service(status::status)
+221-3
src/api/status.rs
···11+use crate::config::Config;
22+use crate::emoji::is_builtin_slug;
13use crate::resolver::HickoryDnsTxtResolver;
24use crate::{
35 api::auth::OAuthClientType,
···911 rate_limiter::RateLimiter,
1012 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate},
1113};
1414+use actix_multipart::Multipart;
1215use actix_session::Session;
1316use actix_web::{
1417 HttpRequest, HttpResponse, Responder, Result, get, post,
···2629 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
2730};
2831use atrium_oauth::DefaultHttpClient;
3232+use futures_util::TryStreamExt as _;
2933use serde::{Deserialize, Serialize};
3034use std::{collections::HashMap, sync::Arc};
3135···127131 vec![]
128132 });
129133134134+ let is_admin_flag = is_admin(did.as_str());
130135 let html = StatusTemplate {
131136 title: "your status",
132137 handle,
133138 current_status,
134139 history,
135140 is_owner: true, // They're viewing their own status
141141+ is_admin: is_admin_flag,
136142 }
137143 .render()
138144 .expect("template should be valid");
···189195 current_status,
190196 history,
191197 is_owner: false, // Visitor viewing owner's status
198198+ is_admin: false,
192199 }
193200 .render()
194201 .expect("template should be valid");
···272279 vec![]
273280 });
274281282282+ let is_admin_flag = match session.get::<String>("did").unwrap_or(None) {
283283+ Some(d) => is_admin(&d),
284284+ None => false,
285285+ };
275286 let html = StatusTemplate {
276287 title: &format!("@{} status", handle),
277288 handle: handle.clone(),
278289 current_status,
279290 history,
280291 is_owner,
292292+ is_admin: is_admin_flag,
281293 }
282294 .render()
283295 .expect("template should be valid");
···729741730742/// Get all custom emojis available on the site
731743#[get("/api/custom-emojis")]
732732-pub async fn get_custom_emojis() -> Result<impl Responder> {
744744+pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> {
733745 use std::fs;
734746735747 #[derive(Serialize)]
···738750 filename: String,
739751 }
740752741741- let emojis_dir = "static/emojis";
753753+ let emojis_dir = app_config.emoji_dir.clone();
742754 let mut emojis = Vec::new();
743755744744- if let Ok(entries) = fs::read_dir(emojis_dir) {
756756+ if let Ok(entries) = fs::read_dir(&emojis_dir) {
745757 for entry in entries.flatten() {
746758 if let Some(filename) = entry.file_name().to_str() {
747759 // Only include image files
···769781 emojis.sort_by(|a, b| a.name.cmp(&b.name));
770782771783 Ok(HttpResponse::Ok().json(emojis))
784784+}
785785+786786+/// Admin-only upload of a custom emoji (PNG or GIF)
787787+#[post("/admin/upload-emoji")]
788788+pub async fn upload_emoji(
789789+ session: Session,
790790+ app_config: web::Data<Config>,
791791+ mut payload: Multipart,
792792+) -> Result<impl Responder> {
793793+ // Require admin
794794+ let did = match session.get::<String>("did").unwrap_or(None) {
795795+ Some(d) => d,
796796+ None => {
797797+ return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
798798+ "error": "Not authenticated"
799799+ })));
800800+ }
801801+ };
802802+ if !is_admin(&did) {
803803+ return Ok(HttpResponse::Forbidden().json(serde_json::json!({
804804+ "error": "Admin access required"
805805+ })));
806806+ }
807807+808808+ // Parse multipart for optional name and the file
809809+ let mut desired_name: Option<String> = None;
810810+ let mut file_bytes: Option<Vec<u8>> = None;
811811+ let mut file_ext: Option<&'static str> = None; // "png" | "gif"
812812+813813+ const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB cap
814814+815815+ loop {
816816+ let mut field = match payload.try_next().await {
817817+ Ok(Some(f)) => f,
818818+ Ok(None) => break,
819819+ Err(e) => {
820820+ log::warn!("multipart error: {}", e);
821821+ return Ok(HttpResponse::BadRequest()
822822+ .json(serde_json::json!({"error":"Invalid multipart data"})));
823823+ }
824824+ };
825825+ let name = field.name().to_string();
826826+827827+ if name == "name" {
828828+ // Collect small text field
829829+ let mut buf = Vec::new();
830830+ loop {
831831+ match field.try_next().await {
832832+ Ok(Some(chunk)) => {
833833+ buf.extend_from_slice(&chunk);
834834+ if buf.len() > 1024 {
835835+ break;
836836+ }
837837+ }
838838+ Ok(None) => break,
839839+ Err(e) => {
840840+ log::warn!("multipart read error: {}", e);
841841+ return Ok(HttpResponse::BadRequest()
842842+ .json(serde_json::json!({"error":"Invalid multipart data"})));
843843+ }
844844+ }
845845+ }
846846+ if let Ok(s) = String::from_utf8(buf) {
847847+ desired_name = Some(s.trim().to_string());
848848+ }
849849+ continue;
850850+ }
851851+852852+ if name == "file" {
853853+ let ct = field.content_type().cloned();
854854+ let mut ext_guess: Option<&'static str> = match ct.as_ref().map(|m| m.essence_str()) {
855855+ Some("image/png") => Some("png"),
856856+ Some("image/gif") => Some("gif"),
857857+ _ => None,
858858+ };
859859+860860+ // Read file bytes with size cap
861861+ let mut data = Vec::new();
862862+ loop {
863863+ match field.try_next().await {
864864+ Ok(Some(chunk)) => {
865865+ data.extend_from_slice(&chunk);
866866+ if data.len() > MAX_SIZE {
867867+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
868868+ "error": "File too large (max 5MB)"
869869+ })));
870870+ }
871871+ }
872872+ Ok(None) => break,
873873+ Err(e) => {
874874+ log::warn!("file read error: {}", e);
875875+ return Ok(HttpResponse::BadRequest()
876876+ .json(serde_json::json!({"error":"Invalid file upload"})));
877877+ }
878878+ }
879879+ }
880880+881881+ // If content-type was ambiguous, try to infer from magic bytes
882882+ if ext_guess.is_none() && data.len() >= 4 {
883883+ if data.starts_with(&[0x89, b'P', b'N', b'G']) {
884884+ ext_guess = Some("png");
885885+ } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
886886+ ext_guess = Some("gif");
887887+ }
888888+ }
889889+890890+ if ext_guess.is_none() {
891891+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
892892+ "error": "Unsupported file type (only PNG or GIF)"
893893+ })));
894894+ }
895895+896896+ file_ext = ext_guess;
897897+ file_bytes = Some(data);
898898+ }
899899+ }
900900+901901+ let data = match file_bytes {
902902+ Some(d) => d,
903903+ None => {
904904+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
905905+ "error": "Missing file field"
906906+ })));
907907+ }
908908+ };
909909+ let ext = file_ext.unwrap_or("png");
910910+911911+ // Sanitize/derive filename base
912912+ let base = desired_name
913913+ .as_ref()
914914+ .cloned()
915915+ .unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp()));
916916+ let mut safe: String = base
917917+ .chars()
918918+ .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
919919+ .collect();
920920+ if safe.is_empty() {
921921+ safe = "emoji".to_string();
922922+ }
923923+ let mut filename = format!("{}.{}", safe.to_lowercase(), ext);
924924+925925+ // Ensure directory exists and avoid overwrite
926926+ let dir = std::path::Path::new(&app_config.emoji_dir);
927927+ if let Err(e) = std::fs::create_dir_all(dir) {
928928+ log::error!("Failed to create emoji dir {}: {}", app_config.emoji_dir, e);
929929+ return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
930930+ "error": "Filesystem error"
931931+ })));
932932+ }
933933+934934+ // If user provided a name explicitly and it conflicts with a builtin emoji slug, reject
935935+ if desired_name.is_some() && is_builtin_slug(&safe.to_lowercase()).await {
936936+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
937937+ "error": "Name is reserved by a standard emoji.",
938938+ "code": "name_exists",
939939+ "name": safe.to_lowercase(),
940940+ })));
941941+ }
942942+943943+ // If user provided a name explicitly and that base already exists with any supported
944944+ // extension, reject with a clear error so the UI can prompt to choose a different name.
945945+ if desired_name.is_some() {
946946+ let png_path = dir.join(format!("{}.png", safe.to_lowercase()));
947947+ let gif_path = dir.join(format!("{}.gif", safe.to_lowercase()));
948948+ if png_path.exists() || gif_path.exists() {
949949+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
950950+ "error": "Name already exists. Choose a different name.",
951951+ "code": "name_exists",
952952+ "name": safe.to_lowercase(),
953953+ })));
954954+ }
955955+ }
956956+957957+ let mut path = dir.join(&filename);
958958+ if path.exists() {
959959+ // Only auto-deconflict when name wasn't provided explicitly
960960+ if desired_name.is_none() {
961961+ for i in 1..1000 {
962962+ filename = format!("{}-{}.{}", safe.to_lowercase(), i, ext);
963963+ path = dir.join(&filename);
964964+ if !path.exists() {
965965+ break;
966966+ }
967967+ }
968968+ } else {
969969+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
970970+ "error": "Name already exists. Choose a different name.",
971971+ "code": "name_exists",
972972+ "name": safe.to_lowercase(),
973973+ })));
974974+ }
975975+ }
976976+977977+ if let Err(e) = std::fs::write(&path, &data) {
978978+ log::error!("Failed to save emoji to {:?}: {}", path, e);
979979+ return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
980980+ "error": "Write failed"
981981+ })));
982982+ }
983983+984984+ let url = format!("/emojis/{}", filename);
985985+ Ok(HttpResponse::Ok().json(serde_json::json!({
986986+ "success": true,
987987+ "filename": filename,
988988+ "url": url
989989+ })))
772990}
773991774992/// Get the DIDs of accounts the logged-in user follows
+5
src/config.rs
···31313232 /// Dev mode for testing with dummy data
3333 pub dev_mode: bool,
3434+3535+ /// Directory to serve and manage custom emojis from
3636+ pub emoji_dir: String,
3437}
35383639impl Config {
···6063 .unwrap_or_else(|_| "false".to_string())
6164 .parse()
6265 .unwrap_or(false),
6666+ // Default to static/emojis for local dev; override in prod to /data/emojis
6767+ emoji_dir: env::var("EMOJI_DIR").unwrap_or_else(|_| "static/emojis".to_string()),
6368 })
6469 }
6570}
+110
src/emoji.rs
···11+use once_cell::sync::OnceCell;
22+use std::{collections::HashSet, fs, path::Path, sync::Arc};
33+44+use crate::config::Config;
55+66+/// Ensure the runtime emoji directory exists, and seed it from the bundled
77+/// `static/emojis` on first run if the runtime directory is empty.
88+pub fn init_runtime_dir(config: &Config) {
99+ let runtime_emoji_dir = &config.emoji_dir;
1010+ let bundled_emoji_dir = "static/emojis";
1111+1212+ if let Err(e) = fs::create_dir_all(runtime_emoji_dir) {
1313+ log::warn!(
1414+ "Failed to ensure emoji directory exists at {}: {}",
1515+ runtime_emoji_dir,
1616+ e
1717+ );
1818+ return;
1919+ }
2020+2121+ let should_seed = runtime_emoji_dir != bundled_emoji_dir
2222+ && fs::read_dir(runtime_emoji_dir)
2323+ .map(|mut it| it.next().is_none())
2424+ .unwrap_or(false);
2525+2626+ if !should_seed {
2727+ return;
2828+ }
2929+3030+ if !Path::new(bundled_emoji_dir).exists() {
3131+ return;
3232+ }
3333+3434+ match fs::read_dir(bundled_emoji_dir) {
3535+ Ok(entries) => {
3636+ for entry in entries.flatten() {
3737+ let path = entry.path();
3838+ if let Some(name) = path.file_name() {
3939+ let dest = Path::new(runtime_emoji_dir).join(name);
4040+ if path.is_file() {
4141+ if let Err(err) = fs::copy(&path, &dest) {
4242+ log::warn!("Failed to seed emoji {:?} -> {:?}: {}", path, dest, err);
4343+ }
4444+ }
4545+ }
4646+ }
4747+ log::info!(
4848+ "Seeded emoji directory {} from {}",
4949+ runtime_emoji_dir,
5050+ bundled_emoji_dir
5151+ );
5252+ }
5353+ Err(err) => log::warn!(
5454+ "Failed to read bundled emoji directory {}: {}",
5555+ bundled_emoji_dir,
5656+ err
5757+ ),
5858+ }
5959+}
6060+6161+static BUILTIN_SLUGS: OnceCell<Arc<HashSet<String>>> = OnceCell::new();
6262+6363+async fn load_builtin_slugs_inner() -> Arc<HashSet<String>> {
6464+ // Fetch emoji data and collect first short_name as slug
6565+ let url = "https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json";
6666+ let client = reqwest::Client::new();
6767+ let mut set = HashSet::new();
6868+ if let Ok(resp) = client.get(url).send().await {
6969+ if let Ok(json) = resp.json::<serde_json::Value>().await {
7070+ if let Some(arr) = json.as_array() {
7171+ for item in arr {
7272+ if let Some(shorts) = item.get("short_names").and_then(|v| v.as_array()) {
7373+ if let Some(first) = shorts.first().and_then(|v| v.as_str()) {
7474+ set.insert(first.to_lowercase());
7575+ }
7676+ } else if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
7777+ // Fallback: slugify the name
7878+ let slug: String = name
7979+ .chars()
8080+ .map(|c| {
8181+ if c.is_ascii_alphanumeric() {
8282+ c.to_ascii_lowercase()
8383+ } else {
8484+ '-'
8585+ }
8686+ })
8787+ .collect::<String>()
8888+ .trim_matches('-')
8989+ .to_string();
9090+ if !slug.is_empty() {
9191+ set.insert(slug);
9292+ }
9393+ }
9494+ }
9595+ }
9696+ }
9797+ }
9898+ Arc::new(set)
9999+}
100100+101101+pub async fn is_builtin_slug(name: &str) -> bool {
102102+ let name = name.to_lowercase();
103103+ if let Some(cache) = BUILTIN_SLUGS.get() {
104104+ return cache.contains(&name);
105105+ }
106106+ let set = load_builtin_slugs_inner().await;
107107+ let contains = set.contains(&name);
108108+ let _ = BUILTIN_SLUGS.set(set);
109109+ contains
110110+}
+17-2
src/main.rs
···3131mod config;
3232mod db;
3333mod dev_utils;
3434+mod emoji;
3435mod error_handler;
3536mod ingester;
3637#[allow(dead_code)]
···190191 // Create rate limiter - 30 requests per minute per IP
191192 let rate_limiter = web::Data::new(RateLimiter::new(30, Duration::from_secs(60)));
192193194194+ // Initialize runtime emoji directory (kept out of main for clarity)
195195+ emoji::init_runtime_dir(&config);
196196+193197 log::debug!("starting HTTP server at http://{host}:{port}");
194198 HttpServer::new(move || {
195199 App::new()
···210214 .build(),
211215 )
212216 .service(Files::new("/static", "static").show_files_listing())
213213- .service(Files::new("/emojis", "static/emojis").show_files_listing())
217217+ .service(
218218+ Files::new("/emojis", app_config.emoji_dir.clone())
219219+ .use_last_modified(true)
220220+ .use_etag(true)
221221+ .show_files_listing(),
222222+ )
214223 .configure(api::configure_routes)
215224 })
216225 .bind((host.as_str(), port))?
···233242 #[actix_web::test]
234243 async fn test_custom_emojis_endpoint() {
235244 // Test that the custom emojis endpoint returns JSON
236236- let app = test::init_service(App::new().service(get_custom_emojis)).await;
245245+ let cfg = crate::config::Config::from_env().expect("load config");
246246+ let app = test::init_service(
247247+ App::new()
248248+ .app_data(web::Data::new(cfg))
249249+ .service(get_custom_emojis),
250250+ )
251251+ .await;
237252238253 let req = test::TestRequest::get()
239254 .uri("/api/custom-emojis")