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

style: rustfmt upload endpoint + imports

+56
Cargo.lock
··· 92 ] 93 94 [[package]] 95 name = "actix-router" 96 version = "0.5.3" 97 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2304 version = "0.1.0" 2305 dependencies = [ 2306 "actix-files", 2307 "actix-session", 2308 "actix-web", 2309 "anyhow", ··· 2317 "chrono", 2318 "dotenv", 2319 "env_logger", 2320 "hickory-resolver", 2321 "log", 2322 "rand 0.8.5", ··· 2481 "smallvec", 2482 "windows-targets 0.52.6", 2483 ] 2484 2485 [[package]] 2486 name = "paste" ··· 3079 "itoa", 3080 "memchr", 3081 "ryu", 3082 "serde", 3083 ] 3084
··· 92 ] 93 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]] 134 name = "actix-router" 135 version = "0.5.3" 136 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2343 version = "0.1.0" 2344 dependencies = [ 2345 "actix-files", 2346 + "actix-multipart", 2347 "actix-session", 2348 "actix-web", 2349 "anyhow", ··· 2357 "chrono", 2358 "dotenv", 2359 "env_logger", 2360 + "futures-util", 2361 "hickory-resolver", 2362 "log", 2363 "rand 0.8.5", ··· 2522 "smallvec", 2523 "windows-targets 0.52.6", 2524 ] 2525 + 2526 + [[package]] 2527 + name = "parse-size" 2528 + version = "1.1.0" 2529 + source = "registry+https://github.com/rust-lang/crates.io-index" 2530 + checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" 2531 2532 [[package]] 2533 name = "paste" ··· 3126 "itoa", 3127 "memchr", 3128 "ryu", 3129 + "serde", 3130 + ] 3131 + 3132 + [[package]] 3133 + name = "serde_plain" 3134 + version = "1.0.2" 3135 + source = "registry+https://github.com/rust-lang/crates.io-index" 3136 + checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" 3137 + dependencies = [ 3138 "serde", 3139 ] 3140
+2
Cargo.toml
··· 9 actix-files = "0.6.6" 10 actix-session = { version = "0.10", features = ["cookie-session"] } 11 actix-web = "4.10.2" 12 anyhow = "1.0.97" 13 askama = "0.13" 14 atrium-common = "0.1.1" ··· 23 serde_json = "1.0.140" 24 rocketman = "0.2.0" 25 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 26 dotenv = "0.15.0" 27 thiserror = "1.0.69" 28 async-sqlite = "0.5.0"
··· 9 actix-files = "0.6.6" 10 actix-session = { version = "0.10", features = ["cookie-session"] } 11 actix-web = "4.10.2" 12 + actix-multipart = "0.6" 13 anyhow = "1.0.97" 14 askama = "0.13" 15 atrium-common = "0.1.1" ··· 24 serde_json = "1.0.140" 25 rocketman = "0.2.0" 26 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 27 + futures-util = "0.3" 28 dotenv = "0.15.0" 29 thiserror = "1.0.69" 30 async-sqlite = "0.5.0"
+21
README.md
··· 52 53 The app serves them at `/emojis/<filename>` and lists them via `/api/custom-emojis`. 54 55 ### available commands 56 57 we use [just](https://github.com/casey/just) for common tasks:
··· 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 77 78 we use [just](https://github.com/casey/just) for common tasks:
+1
src/api/mod.rs
··· 28 // Emoji API routes 29 .service(status::get_frequent_emojis) 30 .service(status::get_custom_emojis) 31 .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)
+173
src/api/status.rs
··· 10 rate_limiter::RateLimiter, 11 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate}, 12 }; 13 use actix_session::Session; 14 use actix_web::{ 15 HttpRequest, HttpResponse, Responder, Result, get, post, ··· 27 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 28 }; 29 use atrium_oauth::DefaultHttpClient; 30 use serde::{Deserialize, Serialize}; 31 use std::{collections::HashMap, sync::Arc}; 32 ··· 770 emojis.sort_by(|a, b| a.name.cmp(&b.name)); 771 772 Ok(HttpResponse::Ok().json(emojis)) 773 } 774 775 /// Get the DIDs of accounts the logged-in user follows
··· 10 rate_limiter::RateLimiter, 11 templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate}, 12 }; 13 + use actix_multipart::Multipart; 14 use actix_session::Session; 15 use actix_web::{ 16 HttpRequest, HttpResponse, Responder, Result, get, post, ··· 28 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 29 }; 30 use atrium_oauth::DefaultHttpClient; 31 + use futures_util::TryStreamExt as _; 32 use serde::{Deserialize, Serialize}; 33 use std::{collections::HashMap, sync::Arc}; 34 ··· 772 emojis.sort_by(|a, b| a.name.cmp(&b.name)); 773 774 Ok(HttpResponse::Ok().json(emojis)) 775 + } 776 + 777 + /// Admin-only upload of a custom emoji (PNG or GIF) 778 + #[post("/admin/upload-emoji")] 779 + pub async fn upload_emoji( 780 + session: Session, 781 + app_config: web::Data<Config>, 782 + mut payload: Multipart, 783 + ) -> Result<impl Responder> { 784 + // Require admin 785 + let did = match session.get::<String>("did").unwrap_or(None) { 786 + Some(d) => d, 787 + None => { 788 + return Ok(HttpResponse::Unauthorized().json(serde_json::json!({ 789 + "error": "Not authenticated" 790 + }))); 791 + } 792 + }; 793 + if !is_admin(&did) { 794 + return Ok(HttpResponse::Forbidden().json(serde_json::json!({ 795 + "error": "Admin access required" 796 + }))); 797 + } 798 + 799 + // Parse multipart for optional name and the file 800 + let mut desired_name: Option<String> = None; 801 + let mut file_bytes: Option<Vec<u8>> = None; 802 + let mut file_ext: Option<&'static str> = None; // "png" | "gif" 803 + 804 + const MAX_SIZE: usize = 5 * 1024 * 1024; // 5MB cap 805 + 806 + loop { 807 + let mut field = match payload.try_next().await { 808 + Ok(Some(f)) => f, 809 + Ok(None) => break, 810 + Err(e) => { 811 + log::warn!("multipart error: {}", e); 812 + return Ok(HttpResponse::BadRequest() 813 + .json(serde_json::json!({"error":"Invalid multipart data"}))); 814 + } 815 + }; 816 + let name = field.name().to_string(); 817 + 818 + if name == "name" { 819 + // Collect small text field 820 + let mut buf = Vec::new(); 821 + loop { 822 + match field.try_next().await { 823 + Ok(Some(chunk)) => { 824 + buf.extend_from_slice(&chunk); 825 + if buf.len() > 1024 { 826 + break; 827 + } 828 + } 829 + Ok(None) => break, 830 + Err(e) => { 831 + log::warn!("multipart read error: {}", e); 832 + return Ok(HttpResponse::BadRequest() 833 + .json(serde_json::json!({"error":"Invalid multipart data"}))); 834 + } 835 + } 836 + } 837 + if let Ok(s) = String::from_utf8(buf) { 838 + desired_name = Some(s.trim().to_string()); 839 + } 840 + continue; 841 + } 842 + 843 + if name == "file" { 844 + let ct = field.content_type().cloned(); 845 + let mut ext_guess: Option<&'static str> = match ct.as_ref().map(|m| m.essence_str()) { 846 + Some("image/png") => Some("png"), 847 + Some("image/gif") => Some("gif"), 848 + _ => None, 849 + }; 850 + 851 + // Read file bytes with size cap 852 + let mut data = Vec::new(); 853 + loop { 854 + match field.try_next().await { 855 + Ok(Some(chunk)) => { 856 + data.extend_from_slice(&chunk); 857 + if data.len() > MAX_SIZE { 858 + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 859 + "error": "File too large (max 5MB)" 860 + }))); 861 + } 862 + } 863 + Ok(None) => break, 864 + Err(e) => { 865 + log::warn!("file read error: {}", e); 866 + return Ok(HttpResponse::BadRequest() 867 + .json(serde_json::json!({"error":"Invalid file upload"}))); 868 + } 869 + } 870 + } 871 + 872 + // If content-type was ambiguous, try to infer from magic bytes 873 + if ext_guess.is_none() && data.len() >= 4 { 874 + if data.starts_with(&[0x89, b'P', b'N', b'G']) { 875 + ext_guess = Some("png"); 876 + } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { 877 + ext_guess = Some("gif"); 878 + } 879 + } 880 + 881 + if ext_guess.is_none() { 882 + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 883 + "error": "Unsupported file type (only PNG or GIF)" 884 + }))); 885 + } 886 + 887 + file_ext = ext_guess; 888 + file_bytes = Some(data); 889 + } 890 + } 891 + 892 + let data = match file_bytes { 893 + Some(d) => d, 894 + None => { 895 + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ 896 + "error": "Missing file field" 897 + }))); 898 + } 899 + }; 900 + let ext = file_ext.unwrap_or("png"); 901 + 902 + // Sanitize/derive filename 903 + let base = desired_name.unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp())); 904 + let mut safe: String = base 905 + .chars() 906 + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') 907 + .collect(); 908 + if safe.is_empty() { 909 + safe = "emoji".to_string(); 910 + } 911 + let mut filename = format!("{}.{}", safe.to_lowercase(), ext); 912 + 913 + // Ensure directory exists and avoid overwrite 914 + let dir = std::path::Path::new(&app_config.emoji_dir); 915 + if let Err(e) = std::fs::create_dir_all(dir) { 916 + log::error!("Failed to create emoji dir {}: {}", app_config.emoji_dir, e); 917 + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 918 + "error": "Filesystem error" 919 + }))); 920 + } 921 + 922 + let mut path = dir.join(&filename); 923 + if path.exists() { 924 + for i in 1..1000 { 925 + filename = format!("{}-{}.{}", safe.to_lowercase(), i, ext); 926 + path = dir.join(&filename); 927 + if !path.exists() { 928 + break; 929 + } 930 + } 931 + } 932 + 933 + if let Err(e) = std::fs::write(&path, &data) { 934 + log::error!("Failed to save emoji to {:?}: {}", path, e); 935 + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ 936 + "error": "Write failed" 937 + }))); 938 + } 939 + 940 + let url = format!("/emojis/{}", filename); 941 + Ok(HttpResponse::Ok().json(serde_json::json!({ 942 + "success": true, 943 + "filename": filename, 944 + "url": url 945 + }))) 946 } 947 948 /// Get the DIDs of accounts the logged-in user follows