···7# Dev Mode Configuration
8DEV_MODE="false" # Enable dev mode for testing with dummy data. Access via ?dev=true query parameter when enabled.
910-000
···7# Dev Mode Configuration
8DEV_MODE="false" # Enable dev mode for testing with dummy data. Access via ?dev=true query parameter when enabled.
910+# Custom Emojis
11+# Directory to read/write custom emoji image files at runtime.
12+# For local dev, keep under the repo:
13+EMOJI_DIR="static/emojis"
···25# navigate to http://127.0.0.1:8080
26```
2700000000000000000000000000000000000000000000000028### available commands
2930we use [just](https://github.com/casey/just) for common tasks:
···43- [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium))
44- [sqlite](https://www.sqlite.org/) for local storage
45- [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption
46-- [fly.io](https://fly.io/) for hosting
···25# navigate to http://127.0.0.1:8080
26```
2728+### custom emojis (no redeploys)
29+30+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`.
31+32+- Local dev: add image files to `static/emojis/` (or set `EMOJI_DIR` in `.env`).
33+- Production (Fly.io): upload files directly into the mounted volume at `/data/emojis` — no rebuild or redeploy needed.
34+35+Examples with Fly CLI:
36+37+```bash
38+# Open an SSH console to the machine
39+fly ssh console -a zzstoatzz-status
40+41+# Inside the VM, copy or fetch files into /data/emojis
42+mkdir -p /data/emojis
43+curl -L -o /data/emojis/my_new_emoji.png https://example.com/my_new_emoji.png
44+```
45+46+Or from your machine using SFTP:
47+48+```bash
49+fly ssh sftp -a zzstoatzz-status
50+sftp> put ./static/emojis/my_new_emoji.png /data/emojis/
51+```
52+53+The app serves them at `/emojis/<filename>` and lists them via `/api/custom-emojis`.
54+55+### admin upload endpoint
56+57+When logged in as the admin DID, you can upload PNG or GIF emojis without SSH via a simple endpoint:
58+59+- Endpoint: `POST /admin/upload-emoji`
60+- Auth: session-based; only the admin DID is allowed
61+- Form fields (multipart/form-data):
62+ - `file`: the image file (PNG or GIF), max 5MB
63+ - `name` (optional): base filename (letters, numbers, `-`, `_`) without extension
64+65+Example with curl:
66+67+```bash
68+curl -i -X POST \
69+ -F "file=@./static/emojis/sample.png" \
70+ -F "name=my_sample" \
71+ http://localhost:8080/admin/upload-emoji
72+```
73+74+Response will include the public URL (e.g., `/emojis/my_sample.png`).
75+76### available commands
7778we use [just](https://github.com/casey/just) for common tasks:
···91- [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium))
92- [sqlite](https://www.sqlite.org/) for local storage
93- [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption
94+- [fly.io](https://fly.io/) for hosting
···28 // Emoji API routes
29 .service(status::get_frequent_emojis)
30 .service(status::get_custom_emojis)
031 .service(status::get_following)
32 // Status management routes
33 .service(status::status)
···28 // Emoji API routes
29 .service(status::get_frequent_emojis)
30 .service(status::get_custom_emojis)
31+ .service(status::upload_emoji)
32 .service(status::get_following)
33 // Status management routes
34 .service(status::status)
+221-3
src/api/status.rs
···001use crate::resolver::HickoryDnsTxtResolver;
2use crate::{
3 api::auth::OAuthClientType,
···9 rate_limiter::RateLimiter,
10 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate},
11};
012use actix_session::Session;
13use actix_web::{
14 HttpRequest, HttpResponse, Responder, Result, get, post,
···26 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
27};
28use atrium_oauth::DefaultHttpClient;
029use serde::{Deserialize, Serialize};
30use std::{collections::HashMap, sync::Arc};
31···127 vec![]
128 });
1290130 let html = StatusTemplate {
131 title: "your status",
132 handle,
133 current_status,
134 history,
135 is_owner: true, // They're viewing their own status
0136 }
137 .render()
138 .expect("template should be valid");
···189 current_status,
190 history,
191 is_owner: false, // Visitor viewing owner's status
0192 }
193 .render()
194 .expect("template should be valid");
···272 vec![]
273 });
2740000275 let html = StatusTemplate {
276 title: &format!("@{} status", handle),
277 handle: handle.clone(),
278 current_status,
279 history,
280 is_owner,
0281 }
282 .render()
283 .expect("template should be valid");
···729730/// Get all custom emojis available on the site
731#[get("/api/custom-emojis")]
732-pub async fn get_custom_emojis() -> Result<impl Responder> {
733 use std::fs;
734735 #[derive(Serialize)]
···738 filename: String,
739 }
740741- let emojis_dir = "static/emojis";
742 let mut emojis = Vec::new();
743744- if let Ok(entries) = fs::read_dir(emojis_dir) {
745 for entry in entries.flatten() {
746 if let Some(filename) = entry.file_name().to_str() {
747 // Only include image files
···769 emojis.sort_by(|a, b| a.name.cmp(&b.name));
770771 Ok(HttpResponse::Ok().json(emojis))
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000772}
773774/// Get the DIDs of accounts the logged-in user follows
···1+use crate::config::Config;
2+use crate::emoji::is_builtin_slug;
3use crate::resolver::HickoryDnsTxtResolver;
4use crate::{
5 api::auth::OAuthClientType,
···11 rate_limiter::RateLimiter,
12 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate},
13};
14+use actix_multipart::Multipart;
15use actix_session::Session;
16use actix_web::{
17 HttpRequest, HttpResponse, Responder, Result, get, post,
···29 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
30};
31use atrium_oauth::DefaultHttpClient;
32+use futures_util::TryStreamExt as _;
33use serde::{Deserialize, Serialize};
34use std::{collections::HashMap, sync::Arc};
35···131 vec![]
132 });
133134+ let is_admin_flag = is_admin(did.as_str());
135 let html = StatusTemplate {
136 title: "your status",
137 handle,
138 current_status,
139 history,
140 is_owner: true, // They're viewing their own status
141+ is_admin: is_admin_flag,
142 }
143 .render()
144 .expect("template should be valid");
···195 current_status,
196 history,
197 is_owner: false, // Visitor viewing owner's status
198+ is_admin: false,
199 }
200 .render()
201 .expect("template should be valid");
···279 vec![]
280 });
281282+ let is_admin_flag = match session.get::<String>("did").unwrap_or(None) {
283+ Some(d) => is_admin(&d),
284+ None => false,
285+ };
286 let html = StatusTemplate {
287 title: &format!("@{} status", handle),
288 handle: handle.clone(),
289 current_status,
290 history,
291 is_owner,
292+ is_admin: is_admin_flag,
293 }
294 .render()
295 .expect("template should be valid");
···741742/// Get all custom emojis available on the site
743#[get("/api/custom-emojis")]
744+pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> {
745 use std::fs;
746747 #[derive(Serialize)]
···750 filename: String,
751 }
752753+ let emojis_dir = app_config.emoji_dir.clone();
754 let mut emojis = Vec::new();
755756+ if let Ok(entries) = fs::read_dir(&emojis_dir) {
757 for entry in entries.flatten() {
758 if let Some(filename) = entry.file_name().to_str() {
759 // Only include image files
···781 emojis.sort_by(|a, b| a.name.cmp(&b.name));
782783 Ok(HttpResponse::Ok().json(emojis))
784+}
785+786+/// Admin-only upload of a custom emoji (PNG or GIF)
787+#[post("/admin/upload-emoji")]
788+pub async fn upload_emoji(
789+ session: Session,
790+ app_config: web::Data<Config>,
791+ mut payload: Multipart,
792+) -> Result<impl Responder> {
793+ // Require admin
794+ let did = match session.get::<String>("did").unwrap_or(None) {
795+ Some(d) => d,
796+ None => {
797+ return Ok(HttpResponse::Unauthorized().json(serde_json::json!({
798+ "error": "Not authenticated"
799+ })));
800+ }
801+ };
802+ if !is_admin(&did) {
803+ return Ok(HttpResponse::Forbidden().json(serde_json::json!({
804+ "error": "Admin access required"
805+ })));
806+ }
807+808+ // Parse multipart for optional name and the file
809+ let mut desired_name: Option<String> = None;
810+ let mut file_bytes: Option<Vec<u8>> = None;
811+ let mut file_ext: Option<&'static str> = None; // "png" | "gif"
812+813+ const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB cap
814+815+ loop {
816+ let mut field = match payload.try_next().await {
817+ Ok(Some(f)) => f,
818+ Ok(None) => break,
819+ Err(e) => {
820+ log::warn!("multipart error: {}", e);
821+ return Ok(HttpResponse::BadRequest()
822+ .json(serde_json::json!({"error":"Invalid multipart data"})));
823+ }
824+ };
825+ let name = field.name().to_string();
826+827+ if name == "name" {
828+ // Collect small text field
829+ let mut buf = Vec::new();
830+ loop {
831+ match field.try_next().await {
832+ Ok(Some(chunk)) => {
833+ buf.extend_from_slice(&chunk);
834+ if buf.len() > 1024 {
835+ break;
836+ }
837+ }
838+ Ok(None) => break,
839+ Err(e) => {
840+ log::warn!("multipart read error: {}", e);
841+ return Ok(HttpResponse::BadRequest()
842+ .json(serde_json::json!({"error":"Invalid multipart data"})));
843+ }
844+ }
845+ }
846+ if let Ok(s) = String::from_utf8(buf) {
847+ desired_name = Some(s.trim().to_string());
848+ }
849+ continue;
850+ }
851+852+ if name == "file" {
853+ let ct = field.content_type().cloned();
854+ let mut ext_guess: Option<&'static str> = match ct.as_ref().map(|m| m.essence_str()) {
855+ Some("image/png") => Some("png"),
856+ Some("image/gif") => Some("gif"),
857+ _ => None,
858+ };
859+860+ // Read file bytes with size cap
861+ let mut data = Vec::new();
862+ loop {
863+ match field.try_next().await {
864+ Ok(Some(chunk)) => {
865+ data.extend_from_slice(&chunk);
866+ if data.len() > MAX_SIZE {
867+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
868+ "error": "File too large (max 5MB)"
869+ })));
870+ }
871+ }
872+ Ok(None) => break,
873+ Err(e) => {
874+ log::warn!("file read error: {}", e);
875+ return Ok(HttpResponse::BadRequest()
876+ .json(serde_json::json!({"error":"Invalid file upload"})));
877+ }
878+ }
879+ }
880+881+ // If content-type was ambiguous, try to infer from magic bytes
882+ if ext_guess.is_none() && data.len() >= 4 {
883+ if data.starts_with(&[0x89, b'P', b'N', b'G']) {
884+ ext_guess = Some("png");
885+ } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
886+ ext_guess = Some("gif");
887+ }
888+ }
889+890+ if ext_guess.is_none() {
891+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
892+ "error": "Unsupported file type (only PNG or GIF)"
893+ })));
894+ }
895+896+ file_ext = ext_guess;
897+ file_bytes = Some(data);
898+ }
899+ }
900+901+ let data = match file_bytes {
902+ Some(d) => d,
903+ None => {
904+ return Ok(HttpResponse::BadRequest().json(serde_json::json!({
905+ "error": "Missing file field"
906+ })));
907+ }
908+ };
909+ let ext = file_ext.unwrap_or("png");
910+911+ // Sanitize/derive filename base
912+ let base = desired_name
913+ .as_ref()
914+ .cloned()
915+ .unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp()));
916+ let mut safe: String = base
917+ .chars()
918+ .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
919+ .collect();
920+ if safe.is_empty() {
921+ safe = "emoji".to_string();
922+ }
923+ let mut filename = format!("{}.{}", safe.to_lowercase(), ext);
924+925+ // Ensure directory exists and avoid overwrite
926+ let dir = std::path::Path::new(&app_config.emoji_dir);
927+ if let Err(e) = std::fs::create_dir_all(dir) {
928+ log::error!("Failed to create emoji dir {}: {}", app_config.emoji_dir, e);
929+ return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
930+ "error": "Filesystem error"
931+ })));
932+ }
933+934+ // If user provided a name explicitly and it conflicts with a builtin emoji slug, reject
935+ if desired_name.is_some() && is_builtin_slug(&safe.to_lowercase()).await {
936+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
937+ "error": "Name is reserved by a standard emoji.",
938+ "code": "name_exists",
939+ "name": safe.to_lowercase(),
940+ })));
941+ }
942+943+ // If user provided a name explicitly and that base already exists with any supported
944+ // extension, reject with a clear error so the UI can prompt to choose a different name.
945+ if desired_name.is_some() {
946+ let png_path = dir.join(format!("{}.png", safe.to_lowercase()));
947+ let gif_path = dir.join(format!("{}.gif", safe.to_lowercase()));
948+ if png_path.exists() || gif_path.exists() {
949+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
950+ "error": "Name already exists. Choose a different name.",
951+ "code": "name_exists",
952+ "name": safe.to_lowercase(),
953+ })));
954+ }
955+ }
956+957+ let mut path = dir.join(&filename);
958+ if path.exists() {
959+ // Only auto-deconflict when name wasn't provided explicitly
960+ if desired_name.is_none() {
961+ for i in 1..1000 {
962+ filename = format!("{}-{}.{}", safe.to_lowercase(), i, ext);
963+ path = dir.join(&filename);
964+ if !path.exists() {
965+ break;
966+ }
967+ }
968+ } else {
969+ return Ok(HttpResponse::Conflict().json(serde_json::json!({
970+ "error": "Name already exists. Choose a different name.",
971+ "code": "name_exists",
972+ "name": safe.to_lowercase(),
973+ })));
974+ }
975+ }
976+977+ if let Err(e) = std::fs::write(&path, &data) {
978+ log::error!("Failed to save emoji to {:?}: {}", path, e);
979+ return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
980+ "error": "Write failed"
981+ })));
982+ }
983+984+ let url = format!("/emojis/{}", filename);
985+ Ok(HttpResponse::Ok().json(serde_json::json!({
986+ "success": true,
987+ "filename": filename,
988+ "url": url
989+ })))
990}
991992/// Get the DIDs of accounts the logged-in user follows
+5
src/config.rs
···3132 /// Dev mode for testing with dummy data
33 pub dev_mode: bool,
00034}
3536impl Config {
···60 .unwrap_or_else(|_| "false".to_string())
61 .parse()
62 .unwrap_or(false),
0063 })
64 }
65}
···3132 /// Dev mode for testing with dummy data
33 pub dev_mode: bool,
34+35+ /// Directory to serve and manage custom emojis from
36+ pub emoji_dir: String,
37}
3839impl Config {
···63 .unwrap_or_else(|_| "false".to_string())
64 .parse()
65 .unwrap_or(false),
66+ // Default to static/emojis for local dev; override in prod to /data/emojis
67+ emoji_dir: env::var("EMOJI_DIR").unwrap_or_else(|_| "static/emojis".to_string()),
68 })
69 }
70}