+56
Cargo.lock
+56
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",
2322
2363
"rand 0.8.5",
···
2481
2522
"smallvec",
2482
2523
"windows-targets 0.52.6",
2483
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"
2484
2531
2485
2532
[[package]]
2486
2533
name = "paste"
···
3079
3126
"itoa",
3080
3127
"memchr",
3081
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 = [
3082
3138
"serde",
3083
3139
]
3084
3140
+2
Cargo.toml
+2
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"
+21
README.md
+21
README.md
···
52
52
53
53
The app serves them at `/emojis/<filename>` and lists them via `/api/custom-emojis`.
54
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
+
55
76
### available commands
56
77
57
78
we use [just](https://github.com/casey/just) for common tasks:
+1
src/api/mod.rs
+1
src/api/mod.rs
+173
src/api/status.rs
+173
src/api/status.rs
···
10
10
rate_limiter::RateLimiter,
11
11
templates::{ErrorTemplate, FeedTemplate, Profile, StatusTemplate},
12
12
};
13
+
use actix_multipart::Multipart;
13
14
use actix_session::Session;
14
15
use actix_web::{
15
16
HttpRequest, HttpResponse, Responder, Result, get, post,
···
27
28
handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
28
29
};
29
30
use atrium_oauth::DefaultHttpClient;
31
+
use futures_util::TryStreamExt as _;
30
32
use serde::{Deserialize, Serialize};
31
33
use std::{collections::HashMap, sync::Arc};
32
34
···
770
772
emojis.sort_by(|a, b| a.name.cmp(&b.name));
771
773
772
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
+
})))
773
946
}
774
947
775
948
/// Get the DIDs of accounts the logged-in user follows