slack status without the slack status.zzstoatzz.io/
quickslice

Merge pull request #45 from zzstoatzz/feat/runtime-emojis-from-volume

Runtime-served custom emojis from Fly.io volume (no redeploys)

authored by

nate nowack and committed by
GitHub
27e93e71 fa47bf92

+774 -16
+4 -1
.env.template
··· 7 7 # Dev Mode Configuration 8 8 DEV_MODE="false" # Enable dev mode for testing with dummy data. Access via ?dev=true query parameter when enabled. 9 9 10 - 10 + # 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"
+57
Cargo.lock
··· 92 92 ] 93 93 94 94 [[package]] 95 + name = "actix-multipart" 96 + version = "0.6.2" 97 + source = "registry+https://github.com/rust-lang/crates.io-index" 98 + checksum = "d974dd6c4f78d102d057c672dcf6faa618fafa9df91d44f9c466688fc1275a3a" 99 + dependencies = [ 100 + "actix-multipart-derive", 101 + "actix-utils", 102 + "actix-web", 103 + "bytes", 104 + "derive_more 0.99.19", 105 + "futures-core", 106 + "futures-util", 107 + "httparse", 108 + "local-waker", 109 + "log", 110 + "memchr", 111 + "mime", 112 + "rand 0.8.5", 113 + "serde", 114 + "serde_json", 115 + "serde_plain", 116 + "tempfile", 117 + "tokio", 118 + ] 119 + 120 + [[package]] 121 + name = "actix-multipart-derive" 122 + version = "0.6.1" 123 + source = "registry+https://github.com/rust-lang/crates.io-index" 124 + checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" 125 + dependencies = [ 126 + "darling", 127 + "parse-size", 128 + "proc-macro2", 129 + "quote", 130 + "syn", 131 + ] 132 + 133 + [[package]] 95 134 name = "actix-router" 96 135 version = "0.5.3" 97 136 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2304 2343 version = "0.1.0" 2305 2344 dependencies = [ 2306 2345 "actix-files", 2346 + "actix-multipart", 2307 2347 "actix-session", 2308 2348 "actix-web", 2309 2349 "anyhow", ··· 2317 2357 "chrono", 2318 2358 "dotenv", 2319 2359 "env_logger", 2360 + "futures-util", 2320 2361 "hickory-resolver", 2321 2362 "log", 2363 + "once_cell", 2322 2364 "rand 0.8.5", 2323 2365 "reqwest", 2324 2366 "rocketman", ··· 2481 2523 "smallvec", 2482 2524 "windows-targets 0.52.6", 2483 2525 ] 2526 + 2527 + [[package]] 2528 + name = "parse-size" 2529 + version = "1.1.0" 2530 + source = "registry+https://github.com/rust-lang/crates.io-index" 2531 + checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" 2484 2532 2485 2533 [[package]] 2486 2534 name = "paste" ··· 3079 3127 "itoa", 3080 3128 "memchr", 3081 3129 "ryu", 3130 + "serde", 3131 + ] 3132 + 3133 + [[package]] 3134 + name = "serde_plain" 3135 + version = "1.0.2" 3136 + source = "registry+https://github.com/rust-lang/crates.io-index" 3137 + checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" 3138 + dependencies = [ 3082 3139 "serde", 3083 3140 ] 3084 3141
+3
Cargo.toml
··· 9 9 actix-files = "0.6.6" 10 10 actix-session = { version = "0.10", features = ["cookie-session"] } 11 11 actix-web = "4.10.2" 12 + actix-multipart = "0.6" 12 13 anyhow = "1.0.97" 13 14 askama = "0.13" 14 15 atrium-common = "0.1.1" ··· 23 24 serde_json = "1.0.140" 24 25 rocketman = "0.2.0" 25 26 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 27 + futures-util = "0.3" 26 28 dotenv = "0.15.0" 27 29 thiserror = "1.0.69" 28 30 async-sqlite = "0.5.0" 29 31 async-trait = "0.1.88" 30 32 rand = "0.8" 31 33 reqwest = { version = "0.12", features = ["json"] } 34 + once_cell = "1.19" 32 35 33 36 [build-dependencies] 34 37 askama = "0.13"
+49 -1
README.md
··· 25 25 # navigate to http://127.0.0.1:8080 26 26 ``` 27 27 28 + ### 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 + 28 76 ### available commands 29 77 30 78 we use [just](https://github.com/casey/just) for common tasks: ··· 43 91 - [at protocol](https://atproto.com/) (via [atrium-rs](https://github.com/sugyan/atrium)) 44 92 - [sqlite](https://www.sqlite.org/) for local storage 45 93 - [jetstream](https://github.com/bluesky-social/jetstream) for firehose consumption 46 - - [fly.io](https://fly.io/) for hosting 94 + - [fly.io](https://fly.io/) for hosting
+2 -1
fly.toml
··· 10 10 DATABASE_URL = "sqlite:///data/status.db" 11 11 ENABLE_FIREHOSE = "true" 12 12 OAUTH_REDIRECT_BASE = "https://status.zzstoatzz.io" 13 + EMOJI_DIR = "/data/emojis" 13 14 14 15 [http_service] 15 16 internal_port = 8080 ··· 30 31 [[vm]] 31 32 cpu_kind = "shared" 32 33 cpus = 1 33 - memory_mb = 512 34 + memory_mb = 512
+1
src/api/mod.rs
··· 28 28 // Emoji API routes 29 29 .service(status::get_frequent_emojis) 30 30 .service(status::get_custom_emojis) 31 + .service(status::upload_emoji) 31 32 .service(status::get_following) 32 33 // Status management routes 33 34 .service(status::status)
+221 -3
src/api/status.rs
··· 1 + use crate::config::Config; 2 + use crate::emoji::is_builtin_slug; 1 3 use crate::resolver::HickoryDnsTxtResolver; 2 4 use crate::{ 3 5 api::auth::OAuthClientType, ··· 9 11 rate_limiter::RateLimiter, 10 12 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate}, 11 13 }; 14 + use actix_multipart::Multipart; 12 15 use actix_session::Session; 13 16 use actix_web::{ 14 17 HttpRequest, HttpResponse, Responder, Result, get, post, ··· 26 29 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 27 30 }; 28 31 use atrium_oauth::DefaultHttpClient; 32 + use futures_util::TryStreamExt as _; 29 33 use serde::{Deserialize, Serialize}; 30 34 use std::{collections::HashMap, sync::Arc}; 31 35 ··· 127 131 vec![] 128 132 }); 129 133 134 + let is_admin_flag = is_admin(did.as_str()); 130 135 let html = StatusTemplate { 131 136 title: "your status", 132 137 handle, 133 138 current_status, 134 139 history, 135 140 is_owner: true, // They're viewing their own status 141 + is_admin: is_admin_flag, 136 142 } 137 143 .render() 138 144 .expect("template should be valid"); ··· 189 195 current_status, 190 196 history, 191 197 is_owner: false, // Visitor viewing owner's status 198 + is_admin: false, 192 199 } 193 200 .render() 194 201 .expect("template should be valid"); ··· 272 279 vec![] 273 280 }); 274 281 282 + let is_admin_flag = match session.get::<String>("did").unwrap_or(None) { 283 + Some(d) => is_admin(&d), 284 + None => false, 285 + }; 275 286 let html = StatusTemplate { 276 287 title: &format!("@{} status", handle), 277 288 handle: handle.clone(), 278 289 current_status, 279 290 history, 280 291 is_owner, 292 + is_admin: is_admin_flag, 281 293 } 282 294 .render() 283 295 .expect("template should be valid"); ··· 729 741 730 742 /// Get all custom emojis available on the site 731 743 #[get("/api/custom-emojis")] 732 - pub async fn get_custom_emojis() -> Result<impl Responder> { 744 + pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> { 733 745 use std::fs; 734 746 735 747 #[derive(Serialize)] ··· 738 750 filename: String, 739 751 } 740 752 741 - let emojis_dir = "static/emojis"; 753 + let emojis_dir = app_config.emoji_dir.clone(); 742 754 let mut emojis = Vec::new(); 743 755 744 - if let Ok(entries) = fs::read_dir(emojis_dir) { 756 + if let Ok(entries) = fs::read_dir(&emojis_dir) { 745 757 for entry in entries.flatten() { 746 758 if let Some(filename) = entry.file_name().to_str() { 747 759 // Only include image files ··· 769 781 emojis.sort_by(|a, b| a.name.cmp(&b.name)); 770 782 771 783 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 + }))) 772 990 } 773 991 774 992 /// Get the DIDs of accounts the logged-in user follows
+5
src/config.rs
··· 31 31 32 32 /// Dev mode for testing with dummy data 33 33 pub dev_mode: bool, 34 + 35 + /// Directory to serve and manage custom emojis from 36 + pub emoji_dir: String, 34 37 } 35 38 36 39 impl Config { ··· 60 63 .unwrap_or_else(|_| "false".to_string()) 61 64 .parse() 62 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()), 63 68 }) 64 69 } 65 70 }
+110
src/emoji.rs
··· 1 + use once_cell::sync::OnceCell; 2 + use std::{collections::HashSet, fs, path::Path, sync::Arc}; 3 + 4 + use crate::config::Config; 5 + 6 + /// Ensure the runtime emoji directory exists, and seed it from the bundled 7 + /// `static/emojis` on first run if the runtime directory is empty. 8 + pub fn init_runtime_dir(config: &Config) { 9 + let runtime_emoji_dir = &config.emoji_dir; 10 + let bundled_emoji_dir = "static/emojis"; 11 + 12 + if let Err(e) = fs::create_dir_all(runtime_emoji_dir) { 13 + log::warn!( 14 + "Failed to ensure emoji directory exists at {}: {}", 15 + runtime_emoji_dir, 16 + e 17 + ); 18 + return; 19 + } 20 + 21 + let should_seed = runtime_emoji_dir != bundled_emoji_dir 22 + && fs::read_dir(runtime_emoji_dir) 23 + .map(|mut it| it.next().is_none()) 24 + .unwrap_or(false); 25 + 26 + if !should_seed { 27 + return; 28 + } 29 + 30 + if !Path::new(bundled_emoji_dir).exists() { 31 + return; 32 + } 33 + 34 + match fs::read_dir(bundled_emoji_dir) { 35 + Ok(entries) => { 36 + for entry in entries.flatten() { 37 + let path = entry.path(); 38 + if let Some(name) = path.file_name() { 39 + let dest = Path::new(runtime_emoji_dir).join(name); 40 + if path.is_file() { 41 + if let Err(err) = fs::copy(&path, &dest) { 42 + log::warn!("Failed to seed emoji {:?} -> {:?}: {}", path, dest, err); 43 + } 44 + } 45 + } 46 + } 47 + log::info!( 48 + "Seeded emoji directory {} from {}", 49 + runtime_emoji_dir, 50 + bundled_emoji_dir 51 + ); 52 + } 53 + Err(err) => log::warn!( 54 + "Failed to read bundled emoji directory {}: {}", 55 + bundled_emoji_dir, 56 + err 57 + ), 58 + } 59 + } 60 + 61 + static BUILTIN_SLUGS: OnceCell<Arc<HashSet<String>>> = OnceCell::new(); 62 + 63 + async fn load_builtin_slugs_inner() -> Arc<HashSet<String>> { 64 + // Fetch emoji data and collect first short_name as slug 65 + let url = "https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json"; 66 + let client = reqwest::Client::new(); 67 + let mut set = HashSet::new(); 68 + if let Ok(resp) = client.get(url).send().await { 69 + if let Ok(json) = resp.json::<serde_json::Value>().await { 70 + if let Some(arr) = json.as_array() { 71 + for item in arr { 72 + if let Some(shorts) = item.get("short_names").and_then(|v| v.as_array()) { 73 + if let Some(first) = shorts.first().and_then(|v| v.as_str()) { 74 + set.insert(first.to_lowercase()); 75 + } 76 + } else if let Some(name) = item.get("name").and_then(|v| v.as_str()) { 77 + // Fallback: slugify the name 78 + let slug: String = name 79 + .chars() 80 + .map(|c| { 81 + if c.is_ascii_alphanumeric() { 82 + c.to_ascii_lowercase() 83 + } else { 84 + '-' 85 + } 86 + }) 87 + .collect::<String>() 88 + .trim_matches('-') 89 + .to_string(); 90 + if !slug.is_empty() { 91 + set.insert(slug); 92 + } 93 + } 94 + } 95 + } 96 + } 97 + } 98 + Arc::new(set) 99 + } 100 + 101 + pub async fn is_builtin_slug(name: &str) -> bool { 102 + let name = name.to_lowercase(); 103 + if let Some(cache) = BUILTIN_SLUGS.get() { 104 + return cache.contains(&name); 105 + } 106 + let set = load_builtin_slugs_inner().await; 107 + let contains = set.contains(&name); 108 + let _ = BUILTIN_SLUGS.set(set); 109 + contains 110 + }
+17 -2
src/main.rs
··· 31 31 mod config; 32 32 mod db; 33 33 mod dev_utils; 34 + mod emoji; 34 35 mod error_handler; 35 36 mod ingester; 36 37 #[allow(dead_code)] ··· 190 191 // Create rate limiter - 30 requests per minute per IP 191 192 let rate_limiter = web::Data::new(RateLimiter::new(30, Duration::from_secs(60))); 192 193 194 + // Initialize runtime emoji directory (kept out of main for clarity) 195 + emoji::init_runtime_dir(&config); 196 + 193 197 log::debug!("starting HTTP server at http://{host}:{port}"); 194 198 HttpServer::new(move || { 195 199 App::new() ··· 210 214 .build(), 211 215 ) 212 216 .service(Files::new("/static", "static").show_files_listing()) 213 - .service(Files::new("/emojis", "static/emojis").show_files_listing()) 217 + .service( 218 + Files::new("/emojis", app_config.emoji_dir.clone()) 219 + .use_last_modified(true) 220 + .use_etag(true) 221 + .show_files_listing(), 222 + ) 214 223 .configure(api::configure_routes) 215 224 }) 216 225 .bind((host.as_str(), port))? ··· 233 242 #[actix_web::test] 234 243 async fn test_custom_emojis_endpoint() { 235 244 // Test that the custom emojis endpoint returns JSON 236 - let app = test::init_service(App::new().service(get_custom_emojis)).await; 245 + let cfg = crate::config::Config::from_env().expect("load config"); 246 + let app = test::init_service( 247 + App::new() 248 + .app_data(web::Data::new(cfg)) 249 + .service(get_custom_emojis), 250 + ) 251 + .await; 237 252 238 253 let req = test::TestRequest::get() 239 254 .uri("/api/custom-emojis")
+1
src/templates.rs
··· 36 36 pub current_status: Option<StatusFromDb>, 37 37 pub history: Vec<StatusFromDb>, 38 38 pub is_owner: bool, 39 + pub is_admin: bool, 39 40 } 40 41 41 42 #[derive(Template)]
+26 -4
static/emoji-data.js
··· 12 12 console.log(`Loaded ${emojiData.length} emojis from CDN`); 13 13 14 14 // Transform into a simpler format for our needs 15 - const emojis = {}; 15 + const emojis = {}; // char -> keywords[] 16 + const slugs = {}; // char -> slug (first short_name fallback from name) 17 + const reserved = new Set(); // all slugs 16 18 const categories = { 17 19 frequent: ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏'], 18 20 people: [], ··· 46 48 } 47 49 48 50 emojis[char] = keywords; 51 + 52 + // Pick a slug: prefer the first short_name 53 + let slug = null; 54 + if (emoji.short_names && emoji.short_names.length > 0) { 55 + slug = emoji.short_names[0].toLowerCase(); 56 + } else if (emoji.name) { 57 + slug = emoji.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); 58 + } 59 + if (slug) { 60 + slugs[char] = slug; 61 + reserved.add(slug); 62 + } 49 63 50 64 // Add to category 51 65 const categoryMap = { ··· 67 81 }); 68 82 69 83 console.log(`Built emoji database with ${Object.keys(emojis).length} emojis`); 70 - return { emojis, categories }; 84 + return { emojis, categories, slugs, reserved: Array.from(reserved) }; 71 85 } catch (error) { 72 86 console.error('Failed to load emoji data:', error); 73 87 // Fallback to a minimal set if the CDN fails ··· 89 103 objects: [], 90 104 symbols: ['❤️'], 91 105 flags: [] 92 - } 106 + }, 107 + slugs: { 108 + '😊': 'smile', 109 + '👍': 'thumbsup', 110 + '❤️': 'heart', 111 + '😂': 'joy', 112 + '🎉': 'tada' 113 + }, 114 + reserved: ['smile','thumbsup','heart','joy','tada'] 93 115 }; 94 116 } 95 117 } 96 118 97 119 // Export for use in the main page 98 - window.emojiDataLoader = { loadEmojiData }; 120 + window.emojiDataLoader = { loadEmojiData };
+131 -1
templates/feed.html
··· 45 45 </button> 46 46 </div> 47 47 </header> 48 + 49 + {% if is_admin %} 50 + <!-- Admin Upload (fixed top-left) --> 51 + <div class="admin-panel" id="admin-panel"> 52 + <button class="admin-toggle" id="admin-toggle" title="admin tools" aria-label="admin tools">⚙️</button> 53 + <div class="admin-content" id="admin-content" style="display:none;"> 54 + <div class="admin-section"> 55 + <div class="admin-title">upload emoji</div> 56 + <form id="emoji-upload-form"> 57 + <input type="text" id="emoji-name" placeholder="name (optional)" maxlength="40" /> 58 + <input type="file" id="emoji-file" accept="image/png,image/gif" required /> 59 + <button type="submit">upload</button> 60 + </form> 61 + <div class="admin-msg" id="admin-msg" aria-live="polite"></div> 62 + </div> 63 + </div> 64 + </div> 65 + {% endif %} 48 66 49 67 <!-- Simple Settings (logged in users only) --> 50 68 {% if let Some(p) = &profile %} ··· 738 756 font-size: 1.5rem; 739 757 } 740 758 } 759 + 760 + /* Admin panel (top-left) */ 761 + .admin-panel { 762 + position: fixed; 763 + top: 12px; 764 + left: 12px; 765 + z-index: 9999; 766 + } 767 + .admin-toggle { 768 + background: var(--bg-tertiary); 769 + border: 1px solid var(--border-color); 770 + border-radius: 10px; 771 + padding: 6px 8px; 772 + cursor: pointer; 773 + color: var(--text-secondary); 774 + } 775 + .admin-content { 776 + margin-top: 8px; 777 + background: var(--bg-tertiary); 778 + border: 1px solid var(--border-color); 779 + border-radius: 12px; 780 + padding: 10px; 781 + width: 240px; 782 + box-shadow: var(--shadow-md); 783 + } 784 + .admin-title { 785 + font-size: 12px; 786 + color: var(--text-secondary); 787 + margin-bottom: 6px; 788 + } 789 + .admin-content input[type="text"], 790 + .admin-content input[type="file"] { 791 + width: 100%; 792 + margin-bottom: 8px; 793 + } 794 + .admin-content button[type="submit"] { 795 + width: 100%; 796 + background: var(--accent); 797 + color: #fff; 798 + border: none; 799 + border-radius: 8px; 800 + padding: 6px 8px; 801 + cursor: pointer; 802 + } 803 + .admin-msg { font-size: 12px; color: var(--text-secondary); margin-top: 6px; } 741 804 </style> 742 805 743 806 <script> ··· 1206 1269 }); 1207 1270 }); 1208 1271 }); 1209 - </script> 1272 + </script> 1273 + <script> 1274 + // Admin upload toggles and submit 1275 + document.addEventListener('DOMContentLoaded', function () { 1276 + const toggle = document.getElementById('admin-toggle'); 1277 + const content = document.getElementById('admin-content'); 1278 + const form = document.getElementById('emoji-upload-form'); 1279 + const file = document.getElementById('emoji-file'); 1280 + const name = document.getElementById('emoji-name'); 1281 + const msg = document.getElementById('admin-msg'); 1282 + if (!toggle || !content || !form) return; 1283 + 1284 + toggle.addEventListener('click', () => { 1285 + content.style.display = content.style.display === 'none' ? 'block' : 'none'; 1286 + }); 1287 + 1288 + form.addEventListener('submit', async (e) => { 1289 + e.preventDefault(); 1290 + msg.textContent = ''; 1291 + if (!file.files || file.files.length === 0) { 1292 + msg.textContent = 'choose a PNG or GIF'; 1293 + return; 1294 + } 1295 + // Require a name; prefill from filename if empty 1296 + if (!name.value.trim().length) { 1297 + const base = (file.files[0].name || '').replace(/\.[^.]+$/, ''); 1298 + const sanitized = base.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); 1299 + name.value = sanitized || ''; 1300 + } 1301 + if (!name.value.trim().length) { 1302 + msg.textContent = 'please choose a name'; 1303 + return; 1304 + } 1305 + // Client-side reserved check (best-effort) 1306 + if (window.__reservedEmojiNames && window.__reservedEmojiNames.has(name.value.trim().toLowerCase())) { 1307 + msg.textContent = 'that name is reserved by a standard emoji'; 1308 + return; 1309 + } 1310 + const f = file.files[0]; 1311 + if (!['image/png','image/gif'].includes(f.type)) { 1312 + msg.textContent = 'only PNG or GIF'; 1313 + return; 1314 + } 1315 + const fd = new FormData(); 1316 + fd.append('file', f); 1317 + if (name.value.trim().length) fd.append('name', name.value.trim()); 1318 + try { 1319 + const res = await fetch('/admin/upload-emoji', { method: 'POST', body: fd }); 1320 + const json = await res.json(); 1321 + if (!res.ok || !json.success) { 1322 + if (json && json.code === 'name_exists') { 1323 + msg.textContent = 'that name already exists — please pick another'; 1324 + } else { 1325 + msg.textContent = (json && json.error) || 'upload failed'; 1326 + } 1327 + return; 1328 + } 1329 + // Notify listeners (e.g., emoji picker) and close panel 1330 + document.dispatchEvent(new CustomEvent('custom-emoji-uploaded', { detail: json })); 1331 + content.style.display = 'none'; 1332 + form.reset(); 1333 + msg.textContent = ''; 1334 + } catch (err) { 1335 + msg.textContent = 'network error'; 1336 + } 1337 + }); 1338 + }); 1339 + </script> 1210 1340 {%endblock content%}
+147 -3
templates/status.html
··· 46 46 {% endif %} 47 47 </div> 48 48 </header> 49 + 50 + {% if is_admin %} 51 + <!-- Admin Upload (fixed top-left) --> 52 + <div class="admin-panel" id="admin-panel"> 53 + <button class="admin-toggle" id="admin-toggle" title="admin tools" aria-label="admin tools">⚙️</button> 54 + <div class="admin-content" id="admin-content" style="display:none;"> 55 + <div class="admin-section"> 56 + <div class="admin-title">upload emoji</div> 57 + <form id="emoji-upload-form"> 58 + <input type="text" id="emoji-name" placeholder="name (optional)" maxlength="40" /> 59 + <input type="file" id="emoji-file" accept="image/png,image/gif" required /> 60 + <button type="submit">upload</button> 61 + </form> 62 + <div class="admin-msg" id="admin-msg" aria-live="polite"></div> 63 + </div> 64 + </div> 65 + </div> 66 + {% endif %} 49 67 50 68 <!-- Simple Settings (owner only) --> 51 69 {% if is_owner %} ··· 350 368 align-items: center; 351 369 margin-bottom: 2rem; 352 370 } 371 + 372 + /* Admin panel (top-left) */ 373 + .admin-panel { 374 + position: fixed; 375 + top: 12px; 376 + left: 12px; 377 + z-index: 9999; 378 + } 379 + .admin-toggle { 380 + background: var(--bg-tertiary); 381 + border: 1px solid var(--border-color); 382 + border-radius: 10px; 383 + padding: 6px 8px; 384 + cursor: pointer; 385 + color: var(--text-secondary); 386 + } 387 + .admin-content { 388 + margin-top: 8px; 389 + background: var(--bg-tertiary); 390 + border: 1px solid var(--border-color); 391 + border-radius: 12px; 392 + padding: 10px; 393 + width: 240px; 394 + box-shadow: var(--shadow-md); 395 + } 396 + .admin-title { 397 + font-size: 12px; 398 + color: var(--text-secondary); 399 + margin-bottom: 6px; 400 + } 401 + .admin-content input[type="text"], 402 + .admin-content input[type="file"] { 403 + width: 100%; 404 + margin-bottom: 8px; 405 + } 406 + .admin-content button[type="submit"] { 407 + width: 100%; 408 + background: var(--accent); 409 + color: #fff; 410 + border: none; 411 + border-radius: 8px; 412 + padding: 6px 8px; 413 + cursor: pointer; 414 + } 415 + .admin-msg { font-size: 12px; color: var(--text-secondary); margin-top: 6px; } 353 416 354 417 .header-actions { 355 418 display: flex; ··· 1461 1524 if (window.emojiDataLoader) { 1462 1525 const data = await window.emojiDataLoader.loadEmojiData(); 1463 1526 emojiKeywords = data.emojis; 1527 + window.__emojiSlugs = data.slugs || {}; 1528 + window.__reservedEmojiNames = new Set(data.reserved || []); 1464 1529 1465 1530 // Load frequent emojis from API 1466 1531 let frequentEmojis = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏']; // defaults ··· 1552 1617 1553 1618 // Load custom emojis on page load 1554 1619 loadCustomEmojis(); 1620 + 1621 + // Refresh custom emojis when an admin uploads a new one 1622 + document.addEventListener('custom-emoji-uploaded', async (e) => { 1623 + await loadCustomEmojis(); 1624 + // If picker visible, refresh current category 1625 + if (emojiPicker && emojiPicker.style.display !== 'none') { 1626 + const active = document.querySelector('.emoji-categories .category-btn.active'); 1627 + const cat = active ? active.getAttribute('data-category') : 'frequent'; 1628 + loadEmojiCategory(cat || 'frequent'); 1629 + } 1630 + }); 1555 1631 1556 1632 // Load emoji category 1557 1633 const loadEmojiCategory = async (category) => { ··· 1625 1701 } else { 1626 1702 // Load standard Unicode emojis 1627 1703 const emojis = emojiData[category] || []; 1628 - emojiGrid.innerHTML = emojis.map(emoji => 1629 - `<button type="button" class="emoji-option" data-emoji="${emoji}">${emoji}</button>` 1630 - ).join(''); 1704 + emojiGrid.innerHTML = emojis.map(emoji => { 1705 + const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 1706 + return `<button type="button" class="emoji-option" data-emoji="${emoji}" data-name="${slug}" title="${slug}">${emoji}</button>`; 1707 + }).join(''); 1631 1708 1632 1709 // Add click handlers for standard emojis 1633 1710 emojiGrid.querySelectorAll('.emoji-option').forEach(btn => { ··· 1963 2040 }); 1964 2041 }); 1965 2042 }); 2043 + </script> 2044 + <script> 2045 + // Admin upload toggles and submit 2046 + document.addEventListener('DOMContentLoaded', function () { 2047 + const toggle = document.getElementById('admin-toggle'); 2048 + const content = document.getElementById('admin-content'); 2049 + const form = document.getElementById('emoji-upload-form'); 2050 + const file = document.getElementById('emoji-file'); 2051 + const name = document.getElementById('emoji-name'); 2052 + const msg = document.getElementById('admin-msg'); 2053 + if (!toggle || !content || !form) return; 2054 + 2055 + toggle.addEventListener('click', () => { 2056 + content.style.display = content.style.display === 'none' ? 'block' : 'none'; 2057 + }); 2058 + 2059 + form.addEventListener('submit', async (e) => { 2060 + e.preventDefault(); 2061 + msg.textContent = ''; 2062 + if (!file.files || file.files.length === 0) { 2063 + msg.textContent = 'choose a PNG or GIF'; 2064 + return; 2065 + } 2066 + // Require a name; prefill from filename if empty 2067 + if (!name.value.trim().length) { 2068 + const base = (file.files[0].name || '').replace(/\.[^.]+$/, ''); 2069 + const sanitized = base.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); 2070 + name.value = sanitized || ''; 2071 + } 2072 + if (!name.value.trim().length) { 2073 + msg.textContent = 'please choose a name'; 2074 + return; 2075 + } 2076 + // Client-side reserved check (best-effort) 2077 + if (window.__reservedEmojiNames && window.__reservedEmojiNames.has(name.value.trim().toLowerCase())) { 2078 + msg.textContent = 'that name is reserved by a standard emoji'; 2079 + return; 2080 + } 2081 + const f = file.files[0]; 2082 + if (!['image/png','image/gif'].includes(f.type)) { 2083 + msg.textContent = 'only PNG or GIF'; 2084 + return; 2085 + } 2086 + const fd = new FormData(); 2087 + fd.append('file', f); 2088 + if (name.value.trim().length) fd.append('name', name.value.trim()); 2089 + try { 2090 + const res = await fetch('/admin/upload-emoji', { method: 'POST', body: fd }); 2091 + const json = await res.json(); 2092 + if (!res.ok || !json.success) { 2093 + if (json && json.code === 'name_exists') { 2094 + msg.textContent = 'that name already exists — please pick another'; 2095 + } else { 2096 + msg.textContent = (json && json.error) || 'upload failed'; 2097 + } 2098 + return; 2099 + } 2100 + // Notify listeners (e.g., emoji picker) and close panel 2101 + document.dispatchEvent(new CustomEvent('custom-emoji-uploaded', { detail: json })); 2102 + content.style.display = 'none'; 2103 + form.reset(); 2104 + msg.textContent = ''; 2105 + } catch (err) { 2106 + msg.textContent = 'network error'; 2107 + } 2108 + }); 2109 + }); 1966 2110 </script> 1967 2111 {%endblock content%}