Skip to main content

falcon_server/
lib.rs

1//! Eagle's local HTTP API, served from a falcon
2//! library.
3//!
4//! Responses use Eagle's envelope: `{"status":"success","data":...}`, string
5//! errors as HTTP 500 `{"status":"error","data":"..."}`, unknown paths as 404
6//! and wrong verbs as 405 (both with Eagle's "method not allowed" text).
7//!
8//! A request is served when its `Host` is the server's own address or when
9//! it carries the Developer token ([`Handle::set_token`]) as `token`.
10//!
11//! Falcon's own choices:
12//! - it listens on 127.0.0.1 only;
13//! - it serves library routes and AI Search only (no script injection or
14//!   plugins);
15//! - imports and thumbnail work finish before the reply, so returned ids
16//!   exist and new thumbnails are in place;
17//! - routes that read or write files do so without holding the library lock;
18//! - library history is what the app hands over ([`Handle::set_history`]),
19//!   or else the libraries this server has served, newest first.
20
21mod ai;
22pub mod mcp;
23mod rules;
24mod v2;
25
26pub use ai::{AiQuery, AiSearch, AiStatus};
27
28use std::collections::{HashMap, HashSet};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::{Arc, Mutex, MutexGuard};
32
33use base64::Engine;
34use falcon_library::import::ImportOptions;
35use falcon_library::model::{Folder, Item};
36use falcon_library::search::{self, Query};
37use falcon_library::Library;
38use serde_json::{Map, Value, json};
39use tiny_http::{Header, Method, Request, Response, Server};
40
41/// Eagle's default port; falcon defaults to the next one so both can run.
42pub const EAGLE_PORT: u16 = 41595;
43pub const DEFAULT_PORT: u16 = 41596;
44
45/// The version reported to clients, which check it against Eagle's.
46const API_VERSION: &str = "4.0.0";
47
48pub type Shared = Arc<Mutex<Library>>;
49
50/// Server state beside the library: what `library/history` reports, the
51/// Developer token, the preferences `application/info` reports, and the
52/// folders unlocked over the API this session.
53struct State {
54    history: Mutex<Vec<String>>,
55    token: Mutex<String>,
56    preferences: Mutex<Value>,
57    /// Set when a request changed `preferences` (`preferences/collect/*`).
58    preferences_changed: AtomicBool,
59    /// `(library root, folder id)` pairs opened with `folder/unlock`.
60    unlocked: Mutex<HashSet<(String, String)>>,
61    /// What answers `aiSearch/*`; see [`Handle::set_ai_search`].
62    ai: Mutex<Option<Arc<dyn AiSearch>>>,
63}
64
65impl State {
66    fn new(lib: &Shared) -> State {
67        State {
68            history: Mutex::new(vec![root_of(lib)]),
69            token: Mutex::new(String::new()),
70            preferences: Mutex::new(default_preferences()),
71            preferences_changed: AtomicBool::new(false),
72            unlocked: Mutex::new(HashSet::new()),
73            ai: Mutex::new(None),
74        }
75    }
76
77    fn history(&self) -> Vec<String> {
78        self.history.lock().map(|h| h.clone()).unwrap_or_default()
79    }
80
81    fn remember(&self, path: String) {
82        if let Ok(mut h) = self.history.lock() {
83            h.retain(|p| *p != path);
84            h.insert(0, path);
85        }
86    }
87
88    /// Whether a request is let in: see [`authorized`].
89    fn allows(&self, host_ok: bool, given: Option<&str>) -> bool {
90        let token = self.token.lock().unwrap_or_else(|p| p.into_inner());
91        authorized(host_ok, &token, given)
92    }
93
94    fn preferences(&self) -> Value {
95        self.preferences.lock().unwrap_or_else(|p| p.into_inner()).clone()
96    }
97
98    /// The folder ids of the library at `root` unlocked this session.
99    fn unlocked_in(&self, root: &str) -> HashSet<String> {
100        let u = self.unlocked.lock().unwrap_or_else(|p| p.into_inner());
101        u.iter().filter(|(r, _)| r == root).map(|(_, id)| id.clone()).collect()
102    }
103}
104
105/// The preferences reported before the app hands over its own: the keys
106/// the server itself reads, at Eagle's defaults.
107fn default_preferences() -> Value {
108    json!({"general": {"showCollectModal": "false"}, "developer": {"apiToken": ""}})
109}
110
111/// Serves the API until the process exits.
112pub fn serve(lib: Shared, port: u16) -> std::io::Result<()> {
113    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
114    if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok() {
115        return Err(std::io::Error::other(format!("port {port} is in use (is Eagle running?)")));
116    }
117    let server = Server::http(("127.0.0.1", port)).map_err(std::io::Error::other)?;
118    let state = State::new(&lib);
119    for request in server.incoming_requests() {
120        handle(&lib, &state, port, request);
121    }
122    Ok(())
123}
124
125/// A server running on its own thread; dropping it stops the server.
126pub struct Handle {
127    server: Arc<Server>,
128    thread: Option<std::thread::JoinHandle<()>>,
129    /// The library being served; [`Handle::set_library`] swaps it.
130    slot: Arc<Mutex<Shared>>,
131    state: Arc<State>,
132    pub port: u16,
133}
134
135impl Handle {
136    /// Serves `lib` from the next request on, keeping the port. The old
137    /// library is left as it is (its owner still holds it), and the new
138    /// one joins `library/history`.
139    pub fn set_library(&self, lib: Shared) {
140        self.state.remember(root_of(&lib));
141        *self.slot.lock().unwrap_or_else(|p| p.into_inner()) = lib;
142    }
143
144    /// The library being served now.
145    pub fn library(&self) -> Shared {
146        self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone()
147    }
148
149    /// Replaces what `library/history` reports (Eagle reports its recent
150    /// libraries list), newest first. Duplicates are dropped.
151    pub fn set_history(&self, paths: Vec<String>) {
152        let mut seen = HashSet::new();
153        let paths = paths.into_iter().filter(|p| seen.insert(p.clone())).collect();
154        *self.state.history.lock().unwrap_or_else(|p| p.into_inner()) = paths;
155    }
156
157    /// Sets the Developer token. A request carrying it as `token` (in the
158    /// query string, or in a POST body) is served whatever its `Host`
159    /// header says. An empty token turns this off.
160    pub fn set_token(&self, token: &str) {
161        *self.state.token.lock().unwrap_or_else(|p| p.into_inner()) = token.to_string();
162    }
163
164    /// Sets the preferences `application/info` reports, in Eagle's shape
165    /// (`{"general": {...}, "developer": {...}, ...}`). Values are strings
166    /// where Eagle uses strings (`"true"`, `"false"`).
167    pub fn set_preferences(&self, preferences: Value) {
168        *self.state.preferences.lock().unwrap_or_else(|p| p.into_inner()) = preferences;
169        self.state.preferences_changed.store(false, Ordering::SeqCst);
170    }
171
172    /// Hands over what answers the `aiSearch/*` routes. Without one they
173    /// answer that AI Search isn't installed.
174    pub fn set_ai_search(&self, ai: Arc<dyn AiSearch>) {
175        *self.state.ai.lock().unwrap_or_else(|p| p.into_inner()) = Some(ai);
176    }
177
178    /// The preferences, if a request changed them since the last call or
179    /// [`Handle::set_preferences`] (`preferences/collect/on` and `off` set
180    /// `general.showCollectModal`).
181    pub fn take_preferences(&self) -> Option<Value> {
182        self.state.preferences_changed.swap(false, Ordering::SeqCst).then(|| self.state.preferences())
183    }
184}
185
186fn root_of(lib: &Shared) -> String {
187    match lib.lock() {
188        Ok(l) => l.root().display().to_string(),
189        Err(p) => p.into_inner().root().display().to_string(),
190    }
191}
192
193impl Drop for Handle {
194    fn drop(&mut self) {
195        self.server.unblock();
196        if let Some(t) = self.thread.take() {
197            let _ = t.join();
198        }
199    }
200}
201
202/// Starts serving on the first free port of `ports`, calling `on_request`
203/// after each handled request (for example to repaint a UI).
204pub fn start(
205    lib: Shared,
206    ports: impl IntoIterator<Item = u16>,
207    on_request: impl Fn() + Send + 'static,
208) -> std::io::Result<Handle> {
209    let mut last = None;
210    for port in ports {
211        // macOS lets a 127.0.0.1 bind succeed beside a wildcard listener
212        // (Eagle listens on *:41595), which would capture that server's
213        // localhost traffic. Skip any port where something already answers.
214        let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
215        if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok() {
216            last = Some(format!("port {port} is in use"));
217            continue;
218        }
219        match Server::http(("127.0.0.1", port)) {
220            Ok(server) => {
221                let server = Arc::new(server);
222                let s = server.clone();
223                let state = Arc::new(State::new(&lib));
224                let slot = Arc::new(Mutex::new(lib));
225                let (st, sl) = (state.clone(), slot.clone());
226                let thread = std::thread::spawn(move || {
227                    for request in s.incoming_requests() {
228                        let lib = sl.lock().unwrap_or_else(|p| p.into_inner()).clone();
229                        handle(&lib, &st, port, request);
230                        on_request();
231                    }
232                });
233                return Ok(Handle { server, thread: Some(thread), slot, state, port });
234            }
235            Err(e) => last = Some(e.to_string()),
236        }
237    }
238    Err(std::io::Error::other(last.unwrap_or_else(|| "no ports given".to_string())))
239}
240
241/// A handler outcome before it is wrapped in Eagle's envelope.
242enum Reply {
243    Data(Value),
244    Empty,
245    /// A string rejection (v1): HTTP 500 with the message in `data`.
246    Fail(String),
247    /// A thrown `Error` (v2): HTTP 500 with the text in `message` and the
248    /// error's enumerable fields (none) as `data: {}`.
249    Error(String),
250    BadRequest,
251    NotFound,
252    WrongMethod,
253    Unauthorized,
254    /// A 302 to this location (the `eagle://` link routes).
255    Redirect(String),
256    /// A link route's 404: `{"error": "..."}`.
257    Missing(&'static str),
258    /// An HTML page.
259    Page(&'static str),
260}
261
262fn handle(lib: &Shared, state: &State, port: u16, mut req: Request) {
263    let url = req.url().to_string();
264    let (path, query) = url.split_once('?').unwrap_or((&url, ""));
265    let path = path.trim_end_matches('/').to_string();
266    let path = if path.is_empty() { "/".to_string() } else { path };
267    let mut args = parse_query(query);
268    let method = req.method().clone();
269    if matches!(method, Method::Post | Method::Put) {
270        let mut body = String::new();
271        let _ = req.as_reader().read_to_string(&mut body);
272        if !body.trim().is_empty() {
273            match serde_json::from_str::<Value>(&body) {
274                Ok(Value::Object(m)) => args.extend(m),
275                _ => return send(req, Reply::BadRequest),
276            }
277        }
278    }
279
280    // Eagle serves a request whose Host is its own address, or one that
281    // carries the Developer token (a POST may also put it in the query).
282    let host = req.headers().iter().find(|h| h.field.equiv("Host")).map(|h| h.value.as_str().to_string());
283    let allowed_hosts = [format!("localhost:{port}"), format!("127.0.0.1:{port}"), format!("0.0.0.0:{port}")];
284    let host_ok = host.as_ref().is_some_and(|h| allowed_hosts.contains(h));
285    let token = match args.get("token") {
286        Some(Value::String(t)) => Some(t.clone()),
287        _ => parse_query(query).get("token").and_then(Value::as_str).map(String::from),
288    };
289    if !state.allows(host_ok, token.as_deref()) {
290        return send(req, Reply::Unauthorized);
291    }
292
293    let reply = match path.as_str() {
294        "/item" | "/folder" | "/smart-folder" => link_redirect(&lock(lib), &path, s(&args, "id")),
295        "/api/v2/playground" => Reply::Page(PLAYGROUND),
296        _ => route(lib, state, &method, &path, &args),
297    };
298    send(req, reply);
299}
300
301/// `/item?id=`, `/folder?id=` and `/smart-folder?id=`: a redirect to the
302/// matching `eagle://` link, or 404 when there is no such object.
303fn link_redirect(lib: &Library, path: &str, id: Option<&str>) -> Reply {
304    let id = id.unwrap_or("");
305    let (found, missing) = match path {
306        "/item" => (lib.item(id).is_some(), "File does not exist."),
307        "/folder" => (lib.all_folders().iter().any(|f| f.id == id), "Folder does not exist."),
308        _ => (find_smart(&lib.smart_folders(), id), "Smart Folder does not exist."),
309    };
310    if found && !id.is_empty() { Reply::Redirect(format!("eagle:/{path}/{id}")) } else { Reply::Missing(missing) }
311}
312
313fn find_smart(list: &[falcon_library::smart::SmartFolder], id: &str) -> bool {
314    list.iter().any(|f| f.id == id || find_smart(&f.children, id))
315}
316
317/// The interactive API page served at `/api/v2/playground`.
318const PLAYGROUND: &str = include_str!("playground.html");
319
320fn parse_query(q: &str) -> Map<String, Value> {
321    let mut out = Map::new();
322    for pair in q.split('&').filter(|p| !p.is_empty()) {
323        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
324        out.insert(decode(k), Value::String(decode(v)));
325    }
326    out
327}
328
329fn decode(s: &str) -> String {
330    let bytes = s.as_bytes();
331    let mut out = Vec::with_capacity(bytes.len());
332    let mut i = 0;
333    while i < bytes.len() {
334        match bytes[i] {
335            b'+' => out.push(b' '),
336            b'%' if i + 2 < bytes.len() => {
337                let hex = |c: u8| (c as char).to_digit(16);
338                match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
339                    (Some(h), Some(l)) => {
340                        out.push((h * 16 + l) as u8);
341                        i += 2;
342                    }
343                    _ => out.push(b'%'),
344                }
345            }
346            b => out.push(b),
347        }
348        i += 1;
349    }
350    String::from_utf8_lossy(&out).into_owned()
351}
352
353fn send(req: Request, reply: Reply) {
354    let (code, body) = match reply {
355        Reply::Redirect(to) => {
356            let resp = match Header::from_bytes("Location", to.as_bytes()) {
357                Ok(h) => Response::empty(302).with_header(h),
358                Err(()) => Response::empty(404),
359            };
360            let _ = req.respond(resp);
361            return;
362        }
363        Reply::Missing(m) => {
364            let resp = Response::from_string(json!({"error": m}).to_string())
365                .with_status_code(404)
366                .with_header(Header::from_bytes("Content-Type", "application/json").unwrap());
367            let _ = req.respond(resp);
368            return;
369        }
370        Reply::Page(html) => {
371            let resp = Response::from_string(html).with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap());
372            let _ = req.respond(resp);
373            return;
374        }
375        Reply::Data(d) => (200, json!({"status": "success", "data": d})),
376        Reply::Empty => (200, json!({"status": "success"})),
377        Reply::Fail(m) => (500, json!({"status": "error", "data": m})),
378        Reply::Error(m) => (500, json!({"status": "error", "message": m, "data": {}})),
379        Reply::BadRequest => (400, json!({"status": "error", "code": 400, "message": "Bad Request"})),
380        Reply::NotFound => (404, json!({"status": "error", "code": 404, "message": "method not allowed"})),
381        Reply::WrongMethod => (405, json!({"status": "error", "code": 405, "message": "method not allowed"})),
382        Reply::Unauthorized => (
383            401,
384            json!({"status": "error", "code": 401, "message": "Unauthorized: Access is denied due to invalid token."}),
385        ),
386    };
387    let resp = Response::from_string(body.to_string())
388        .with_status_code(code)
389        .with_header(Header::from_bytes("Content-Type", "application/json; charset=utf-8").unwrap())
390        .with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap());
391    let _ = req.respond(resp);
392}
393
394// ---------------------------------------------------------------------------
395// Argument helpers
396
397fn s<'a>(args: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
398    args.get(key).and_then(Value::as_str).filter(|v| !v.is_empty())
399}
400
401fn strings(args: &Map<String, Value>, key: &str) -> Vec<String> {
402    match args.get(key) {
403        Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
404        Some(Value::String(v)) if !v.is_empty() => v.split(',').map(|x| x.trim().to_string()).collect(),
405        _ => Vec::new(),
406    }
407}
408
409fn number(args: &Map<String, Value>, key: &str) -> Option<u64> {
410    match args.get(key)? {
411        Value::Number(n) => n.as_u64(),
412        Value::String(v) => v.parse().ok(),
413        _ => None,
414    }
415}
416
417/// `folderId` / `folderID` / `folderIds` / `folderIDs`, keeping only ids
418/// that exist (Eagle drops unknown ones).
419fn folder_ids(lib: &Library, args: &Map<String, Value>) -> Vec<String> {
420    let known = lib.folder_ids();
421    let mut ids = Vec::new();
422    for key in ["folderId", "folderID"] {
423        if let Some(v) = s(args, key) {
424            ids.push(v.to_string());
425        }
426    }
427    for key in ["folderIds", "folderIDs"] {
428        ids.extend(strings(args, key));
429    }
430    ids.retain(|i| known.contains(i.as_str()));
431    ids.dedup();
432    ids
433}
434
435fn item_json(item: &Item) -> Value {
436    serde_json::to_value(item).unwrap_or(Value::Null)
437}
438
439/// A folder in `/api/folder/list`'s shape, with Eagle's extra display keys.
440fn folder_json(f: &Folder) -> Value {
441    folder_json_with(f, &HashSet::new())
442}
443
444/// [`folder_json`], where the folders in `unlocked` (opened with
445/// `folder/unlock`) report `isUnLock: true` and keep their children.
446fn folder_json_with(f: &Folder, unlocked: &HashSet<String>) -> Value {
447    let mut v = serde_json::to_value(f).unwrap_or(Value::Null);
448    if let Value::Object(m) = &mut v {
449        m.insert("children".into(), Value::Array(f.children.iter().map(|c| folder_json_with(c, unlocked)).collect()));
450        for (k, d) in [("extendTags", json!([])), ("icon", json!("")), ("iconColor", json!("")), ("coverId", json!(""))] {
451            m.entry(k).or_insert(d);
452        }
453        let open = f.password.is_empty() || unlocked.contains(&f.id);
454        m.insert("isUnLock".into(), json!(open));
455        if !open {
456            m.insert("children".into(), json!([]));
457        }
458    }
459    v
460}
461
462fn tag_objects(lib: &Library) -> Vec<Value> {
463    let mut counts: HashMap<&str, usize> = HashMap::new();
464    for i in lib.items().iter().filter(|i| !i.is_deleted) {
465        for t in &i.tags {
466            *counts.entry(t).or_default() += 1;
467        }
468    }
469    let mut v: Vec<_> = counts.into_iter().collect();
470    v.sort_by(|a, b| search::natural_cmp(a.0, b.0));
471    v.into_iter().map(|(name, n)| json!({"name": name, "imageCount": n})).collect()
472}
473
474fn library_name(lib: &Library) -> String {
475    lib.root().file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
476}
477
478fn library_info(lib: &Library) -> Value {
479    let mut v = serde_json::to_value(&lib.metadata).unwrap_or(json!({}));
480    let name = library_name(lib);
481    if let Value::Object(m) = &mut v {
482        m.insert("library".into(), json!({"path": lib.root().display().to_string(), "name": name}));
483    }
484    v
485}
486
487fn paginate(items: Vec<Value>, args: &Map<String, Value>) -> Value {
488    let offset = number(args, "offset").unwrap_or(0) as usize;
489    let limit = (number(args, "limit").filter(|l| *l > 0).unwrap_or(50) as usize).min(1000);
490    let total = items.len();
491    let page: Vec<Value> = items.into_iter().skip(offset).take(limit).collect();
492    json!({"data": page, "total": total, "offset": offset, "limit": limit})
493}
494
495// ---------------------------------------------------------------------------
496// Routing
497
498fn route(lib: &Shared, state: &State, method: &Method, path: &str, args: &Map<String, Value>) -> Reply {
499    use Method::{Get, Post};
500    let routes: &[(&str, &[Method])] = &[
501        ("/", &[Get]),
502        ("/api/application/info", &[Get]),
503        ("/api/library/info", &[Get]),
504        ("/api/library/history", &[Get]),
505        ("/api/library/switch", &[Post]),
506        ("/api/library/icon", &[Get]),
507        ("/api/folder/list", &[Get]),
508        ("/api/folder/listRecent", &[Get]),
509        ("/api/folder/create", &[Post]),
510        ("/api/folder/rename", &[Post]),
511        ("/api/folder/update", &[Post]),
512        ("/api/folder/unlock", &[Post]),
513        ("/api/preferences/collect/on", &[Get]),
514        ("/api/preferences/collect/off", &[Get]),
515        ("/api/tag/all", &[Get]),
516        ("/api/tag/list", &[Get]),
517        ("/api/tag/listRecent", &[Get]),
518        ("/api/tag/groups", &[Get]),
519        ("/api/item/addFromPath", &[Post]),
520        ("/api/item/addFromPaths", &[Post]),
521        ("/api/item/addFromURL", &[Post]),
522        ("/api/item/addFromURLs", &[Post]),
523        ("/api/item/update", &[Post]),
524        ("/api/item/info", &[Get]),
525        ("/api/item/thumbnail", &[Get]),
526        ("/api/item/moveToTrash", &[Post]),
527        ("/api/item/list", &[Get]),
528        ("/api/item/setCustomThumbnail", &[Post]),
529        ("/api/item/refreshPalette", &[Post]),
530        ("/api/item/refreshThumbnail", &[Post]),
531        ("/api/v2/app/info", &[Get]),
532        ("/api/v2/library/info", &[Get]),
533        ("/api/v2/library/history", &[Get]),
534        ("/api/v2/library/switch", &[Post]),
535        ("/api/v2/library/icon", &[Get]),
536        ("/api/v2/item/get", &[Get, Post]),
537        ("/api/v2/item/query", &[Post]),
538        ("/api/v2/item/countAll", &[Get]),
539        ("/api/v2/item/add", &[Post]),
540        ("/api/v2/item/update", &[Post]),
541        ("/api/v2/item/setCustomThumbnail", &[Post]),
542        ("/api/v2/item/refreshThumbnail", &[Post]),
543        ("/api/v2/item/getComments", &[Get]),
544        ("/api/v2/item/addComment", &[Post]),
545        ("/api/v2/item/updateComment", &[Post]),
546        ("/api/v2/item/removeComment", &[Post]),
547        ("/api/v2/folder/get", &[Get, Post]),
548        ("/api/v2/folder/create", &[Post]),
549        ("/api/v2/folder/update", &[Post]),
550        ("/api/v2/smartFolder/get", &[Get, Post]),
551        ("/api/v2/smartFolder/create", &[Post]),
552        ("/api/v2/smartFolder/update", &[Post]),
553        ("/api/v2/smartFolder/remove", &[Post]),
554        ("/api/v2/smartFolder/getItems", &[Get, Post]),
555        ("/api/v2/smartFolder/getRules", &[Get]),
556        ("/api/v2/tag/get", &[Get, Post]),
557        ("/api/v2/tag/getRecentTags", &[Get]),
558        ("/api/v2/tag/getStarredTags", &[Get]),
559        ("/api/v2/tag/update", &[Post]),
560        ("/api/v2/tag/merge", &[Post]),
561        ("/api/v2/tagGroup/get", &[Get]),
562        ("/api/v2/tagGroup/create", &[Post]),
563        ("/api/v2/tagGroup/update", &[Post]),
564        ("/api/v2/tagGroup/remove", &[Post]),
565        ("/api/v2/tagGroup/addTags", &[Post]),
566        ("/api/v2/tagGroup/removeTags", &[Post]),
567        ("/api/v2/aiSearch/isInstalled", &[Get]),
568        ("/api/v2/aiSearch/isReady", &[Get]),
569        ("/api/v2/aiSearch/isStarting", &[Get]),
570        ("/api/v2/aiSearch/isSyncing", &[Get]),
571        ("/api/v2/aiSearch/getSyncStatus", &[Get]),
572        ("/api/v2/aiSearch/checkServiceHealth", &[Get]),
573        ("/api/v2/aiSearch/searchByText", &[Get, Post]),
574        ("/api/v2/aiSearch/searchByBase64", &[Post]),
575        ("/api/v2/aiSearch/searchByItemId", &[Get, Post]),
576    ];
577    let Some((_, methods)) = routes.iter().find(|(p, _)| *p == path) else {
578        return Reply::NotFound;
579    };
580    if !methods.contains(method) {
581        return Reply::WrongMethod;
582    }
583    if path.starts_with("/api/v2/aiSearch/") {
584        return ai::route(lib, state, path, args);
585    }
586    if let Some(reply) = import_route(lib, path, args) {
587        return reply;
588    }
589    if let Some(reply) = unlocked_route(lib, state, path, args) {
590        return reply;
591    }
592    let mut lib = lock(lib);
593    let lib = &mut *lib;
594
595    match path {
596        "/" | "/api/application/info" => {
597            let preferences = state.preferences();
598            Reply::Data(json!({
599                "version": API_VERSION,
600                "prereleaseVersion": null,
601                "buildVersion": concat!("falcon-", env!("CARGO_PKG_VERSION")),
602                "showCollectModal": preferences["general"]["showCollectModal"] == "true",
603                "platform": std::env::consts::OS,
604                "preferences": preferences,
605            }))
606        }
607        "/api/v2/app/info" => Reply::Data(json!({
608            "version": API_VERSION,
609            "prereleaseVersion": null,
610            "buildVersion": concat!("falcon-", env!("CARGO_PKG_VERSION")),
611            "platform": std::env::consts::OS,
612        })),
613        "/api/library/info" => Reply::Data(library_info(lib)),
614        "/api/v2/library/info" => {
615            // v2 puts `path` and `name` at the top level instead of under `library`.
616            let mut v = serde_json::to_value(&lib.metadata).unwrap_or(json!({}));
617            if let Value::Object(m) = &mut v {
618                m.insert("path".into(), json!(lib.root().display().to_string()));
619                m.insert("name".into(), json!(library_name(lib)));
620            }
621            Reply::Data(v)
622        }
623        "/api/library/history" => Reply::Data(json!(state.history())),
624        "/api/v2/library/history" => Reply::Data(paginate(state.history().into_iter().map(Value::from).collect(), args)),
625        "/api/folder/list" => {
626            let open = state.unlocked_in(&lib.root().display().to_string());
627            Reply::Data(Value::Array(lib.metadata.folders.iter().map(|f| folder_json_with(f, &open)).collect()))
628        }
629        "/api/folder/listRecent" => {
630            let open = state.unlocked_in(&lib.root().display().to_string());
631            Reply::Data(Value::Array(lib.all_folders().into_iter().take(16).map(|f| folder_json_with(f, &open)).collect()))
632        }
633        "/api/folder/unlock" => {
634            let id = s(args, "folderId").unwrap_or("");
635            let Some(folder) = lib.all_folders().into_iter().find(|f| f.id == id) else {
636                return Reply::Fail("Folder does not exist.".into());
637            };
638            // Eagle compares the stored value with the encoded password, so
639            // an empty password opens a folder that has none.
640            let given = args.get("password").and_then(Value::as_str);
641            let opens = match given {
642                Some(p) if folder.is_locked() => lib.verify_folder_password(id, p),
643                Some(p) => p.is_empty(),
644                None => false,
645            };
646            if !opens {
647                return Reply::Fail("Password is incorrect.".into());
648            }
649            let root = lib.root().display().to_string();
650            state.unlocked.lock().unwrap_or_else(|p| p.into_inner()).insert((root, id.to_string()));
651            Reply::Empty
652        }
653        "/api/preferences/collect/on" | "/api/preferences/collect/off" => {
654            let on = path.ends_with("/on");
655            let mut prefs = state.preferences.lock().unwrap_or_else(|p| p.into_inner());
656            let Some(general) = prefs.get_mut("general").and_then(Value::as_object_mut) else {
657                return Reply::Fail("preferences file not exist.".into());
658            };
659            general.insert("showCollectModal".into(), json!(if on { "true" } else { "false" }));
660            state.preferences_changed.store(true, Ordering::SeqCst);
661            Reply::Empty
662        }
663        "/api/folder/create" => {
664            let Some(name) = s(args, "folderName") else { return Reply::Fail("Missing required parameters.".into()) };
665            match lib.create_folder(s(args, "parent"), name) {
666                Ok(id) => folder_reply(lib, &id),
667                Err(e) => Reply::Fail(e.to_string()),
668            }
669        }
670        "/api/folder/rename" => {
671            let (Some(id), Some(name)) = (s(args, "folderId").or(s(args, "folderID")), s(args, "newName")) else {
672                return Reply::Fail("Missing required parameters.".into());
673            };
674            match lib.rename_folder(id, name) {
675                Ok(()) => folder_reply(lib, id),
676                Err(_) => Reply::Fail("Folder does not exist.".into()),
677            }
678        }
679        "/api/folder/update" => {
680            let Some(id) = s(args, "folderId").or(s(args, "folderID")) else {
681                return Reply::Fail("Missing required parameters.".into());
682            };
683            if let Some(name) = s(args, "newName")
684                && lib.rename_folder(id, name).is_err()
685            {
686                return Reply::Fail("Folder does not exist.".into());
687            }
688            if let Some(d) = args.get("newDescription").and_then(Value::as_str)
689                && lib.set_folder_description(id, d).is_err()
690            {
691                return Reply::Fail("Folder does not exist.".into());
692            }
693            folder_reply(lib, id)
694        }
695
696        "/api/tag/all" => {
697            let tags = tag_objects(lib);
698            Reply::Data(json!({
699                "tags": tags,
700                "recent": lib.tags.history_tags,
701                "groups": lib.metadata.tags_groups,
702                "starred": lib.tags.starred_tags,
703            }))
704        }
705        "/api/tag/list" => Reply::Data(Value::Array(tag_objects(lib))),
706        "/api/tag/listRecent" => {
707            let all = tag_objects(lib);
708            let recent: Vec<Value> = lib
709                .tags
710                .history_tags
711                .iter()
712                .filter_map(|t| all.iter().find(|o| o["name"] == *t).cloned())
713                .collect();
714            Reply::Data(Value::Array(recent))
715        }
716        "/api/tag/groups" => Reply::Data(json!(lib.metadata.tags_groups)),
717        "/api/v2/tag/get" => v2::get_tags(lib, args),
718        "/api/v2/tag/getRecentTags" => v2::listed_tags(lib, &lib.tags.history_tags, args),
719        "/api/v2/tag/getStarredTags" => v2::listed_tags(lib, &lib.tags.starred_tags, args),
720        "/api/v2/tag/update" => v2::update_tag(lib, args),
721        "/api/v2/tag/merge" => v2::merge_tags(lib, args),
722        "/api/v2/tagGroup/get" => v2::get_tag_groups(lib, args),
723        "/api/v2/tagGroup/create" => v2::create_tag_group(lib, args),
724        "/api/v2/tagGroup/update" => v2::update_tag_group(lib, args),
725        "/api/v2/tagGroup/remove" => v2::remove_tag_group(lib, args),
726        "/api/v2/tagGroup/addTags" => v2::add_group_tags(lib, args),
727        "/api/v2/tagGroup/removeTags" => v2::remove_group_tags(lib, args),
728        "/api/v2/smartFolder/get" => v2::get_smart_folders(lib, args),
729        "/api/v2/smartFolder/create" => v2::create_smart_folder(lib, args),
730        "/api/v2/smartFolder/update" => v2::update_smart_folder(lib, args),
731        "/api/v2/smartFolder/remove" => v2::remove_smart_folder(lib, args),
732        "/api/v2/smartFolder/getItems" => v2::smart_folder_items(lib, args),
733        "/api/v2/smartFolder/getRules" => Reply::Data(rules::schema()),
734        "/api/v2/item/update" => v2::update_item(lib, args),
735        "/api/v2/item/getComments" => v2::get_comments(lib, args),
736        "/api/v2/item/addComment" => v2::add_comment(lib, args),
737        "/api/v2/item/updateComment" => v2::update_comment(lib, args),
738        "/api/v2/item/removeComment" => v2::remove_comment(lib, args),
739        "/api/v2/folder/create" => v2::create_folder(lib, args),
740        "/api/v2/folder/update" => v2::update_folder(lib, args),
741
742        "/api/item/update" => {
743            let Some(mut item) = s(args, "id").and_then(|id| lib.item(id)).cloned() else {
744                return Reply::Fail("File does not exist.".into());
745            };
746            if let Some(Value::Array(tags)) = args.get("tags") {
747                item.tags = tags.iter().filter_map(|t| t.as_str().map(String::from)).collect();
748            }
749            if let Some(u) = args.get("url").and_then(Value::as_str) {
750                item.url = u.to_string();
751            }
752            if let Some(a) = args.get("annotation").and_then(Value::as_str) {
753                item.annotation = a.to_string();
754            }
755            // Eagle ignores star 0 here.
756            if let Some(star) = number(args, "star").filter(|s| (1..=5).contains(s)) {
757                item.extra.insert("star".into(), json!(star));
758            }
759            let id = item.id.clone();
760            match lib.update_item(item) {
761                Ok(()) => Reply::Data(lib.item(&id).map(item_json).unwrap_or(Value::Null)),
762                Err(e) => Reply::Fail(e.to_string()),
763            }
764        }
765        "/api/item/info" => match s(args, "id").and_then(|id| lib.item(id)) {
766            Some(i) => Reply::Data(item_json(i)),
767            None => Reply::Fail("File does not exist.".into()),
768        },
769        "/api/item/thumbnail" => match s(args, "id").and_then(|id| lib.item(id)) {
770            Some(i) => Reply::Data(json!(thumbnail_path(lib, i))),
771            None => Reply::Fail("File does not exist.".into()),
772        },
773        "/api/item/moveToTrash" => {
774            let ids = strings(args, "itemIds");
775            if ids.is_empty() {
776                return Reply::Fail("Missing required parameters.".into());
777            }
778            match lib.set_trashed(&ids, true) {
779                Ok(()) => Reply::Empty,
780                Err(e) => Reply::Fail(e.to_string()),
781            }
782        }
783        "/api/item/list" => Reply::Data(Value::Array(v1_list(lib, args))),
784
785        "/api/v2/item/get" => match v2::smart_filter(lib, args) {
786            Ok(only) => {
787                let items = v2_get(lib, args, only.as_ref());
788                Reply::Data(paginate(project(items, args), args))
789            }
790            Err(e) => Reply::Error(e),
791        },
792        "/api/v2/item/query" => {
793            let q = Query::parse(s(args, "query").unwrap_or(""));
794            let index = search::folder_index(&lib.metadata.folders);
795            let qlen = s(args, "query").map_or(0, |q| q.chars().count());
796            let mut items: Vec<&Item> =
797                lib.items().iter().filter(|i| !i.is_deleted && q.matches(&search::search_text(i, &index, qlen))).collect();
798            search::sort(&mut items, search::OrderBy::Import, true);
799            let items: Vec<Value> = items.into_iter().map(item_json).collect();
800            Reply::Data(paginate(project(items, args), args))
801        }
802        "/api/v2/item/countAll" => Reply::Data(json!(lib.items().len())),
803        "/api/v2/folder/get" => {
804            let ids = strings(args, "ids");
805            let one = s(args, "id");
806            // With no filter Eagle returns the top-level tree, else a flat list.
807            let folders: Vec<Value> = if ids.is_empty() && one.is_none() {
808                lib.metadata.folders.iter().map(folder_json).collect()
809            } else {
810                lib.all_folders()
811                    .into_iter()
812                    .filter(|f| ids.contains(&f.id) || one == Some(f.id.as_str()))
813                    .map(folder_json)
814                    .collect()
815            };
816            Reply::Data(paginate(folders, args))
817        }
818        _ => Reply::NotFound,
819    }
820}
821
822/// Locks the library, taking it over from a holder that panicked.
823fn lock(lib: &Shared) -> MutexGuard<'_, Library> {
824    lib.lock().unwrap_or_else(|p| p.into_inner())
825}
826
827/// The add routes. They lock the library only briefly (see [`add_path`]),
828/// so the app sharing it stays responsive while a source is read.
829fn import_route(lib: &Shared, path: &str, args: &Map<String, Value>) -> Option<Reply> {
830    Some(match path {
831        "/api/item/addFromPath" => {
832            let Some(p) = s(args, "path") else { return Some(Reply::Fail("Missing required parameters.".into())) };
833            match add_path(lib, PathBuf::from(p), args, args, false) {
834                Ok(id) => Reply::Data(json!(id)),
835                Err(e) => Reply::Fail(e),
836            }
837        }
838        "/api/item/addFromPaths" => {
839            let entries: Vec<Map<String, Value>> = match (args.get("items"), args.get("paths")) {
840                (Some(Value::Array(items)), _) => items.iter().filter_map(|v| v.as_object().cloned()).collect(),
841                (_, Some(Value::Array(paths))) => paths
842                    .iter()
843                    .filter_map(|p| p.as_str())
844                    .map(|p| {
845                        let mut m = Map::new();
846                        m.insert("path".into(), json!(p));
847                        m
848                    })
849                    .collect(),
850                _ => return Some(Reply::BadRequest),
851            };
852            let mut ids = Vec::new();
853            for e in entries {
854                if let Some(p) = s(&e, "path")
855                    && let Ok(id) = add_path(lib, PathBuf::from(p), &e, args, false)
856                {
857                    ids.push(id);
858                }
859            }
860            Reply::Data(json!(ids))
861        }
862        "/api/item/addFromURL" => {
863            let Some(url) = s(args, "url") else { return Some(Reply::Fail("Missing required parameters.".into())) };
864            let added = add_url(lib, url, args, args);
865            record_tags(lib, args);
866            match added {
867                Ok(_) => Reply::Empty,
868                Err(e) => Reply::Fail(e),
869            }
870        }
871        "/api/item/addFromURLs" => {
872            let Some(Value::Array(items)) = args.get("items") else { return Some(Reply::Fail("Missing required parameters.".into())) };
873            for e in items.iter().filter_map(Value::as_object) {
874                if let Some(url) = s(e, "url") {
875                    let _ = add_url(lib, url, e, args);
876                    record_tags(lib, e);
877                }
878            }
879            Reply::Empty
880        }
881        "/api/v2/item/add" => v2::add_item(lib, args),
882        _ => return None,
883    })
884}
885
886/// Routes that read or write files. Like the add routes, they hold the
887/// library lock only to look things up and to commit, never while a file
888/// is read, decoded or written.
889fn unlocked_route(lib: &Shared, state: &State, path: &str, args: &Map<String, Value>) -> Option<Reply> {
890    use falcon_library::{palette, thumbnail};
891    Some(match path {
892        "/api/library/switch" | "/api/v2/library/switch" => {
893            let v2 = path.starts_with("/api/v2/");
894            match v2::open_library(s(args, "libraryPath").unwrap_or("")) {
895                Ok(new) => {
896                    state.remember(new.root().display().to_string());
897                    *lock(lib) = new;
898                    if v2 { Reply::Data(json!(true)) } else { Reply::Empty }
899                }
900                Err(e) if v2 => Reply::Error(e),
901                Err(e) => Reply::Fail(e),
902            }
903        }
904        "/api/library/icon" => v2::library_icon(s(args, "libraryPath")).map_or_else(Reply::Fail, Reply::Data),
905        "/api/v2/library/icon" => v2::library_icon(s(args, "libraryPath")).map_or_else(Reply::Error, Reply::Data),
906
907        // v1 resolves `true` once the thumbnail is in place; v2 answers
908        // `true` or `false` and never throws.
909        "/api/item/setCustomThumbnail" | "/api/v2/item/setCustomThumbnail" => {
910            let v2 = path.starts_with("/api/v2/");
911            let (id, file) = if v2 { (s(args, "itemId"), s(args, "filePath")) } else { (s(args, "id"), s(args, "thumbnailPath")) };
912            let file = PathBuf::from(file.unwrap_or(""));
913            let done = restage(lib, id.unwrap_or(""), |root, item| {
914                thumbnail::custom_thumbnailed(root, item.clone(), &file).map(Some).map_err(|e| e.to_string())
915            });
916            match done {
917                Restaged::Done => Reply::Data(json!(true)),
918                _ if v2 => Reply::Data(json!(false)),
919                Restaged::NoItem => Reply::Fail("File does not exist.".into()),
920                Restaged::Failed => Reply::Fail("Failed to set custom thumbnail.".into()),
921            }
922        }
923        // Eagle queues the work and replies at once, so only an unknown
924        // item is an error; falcon finishes the work first.
925        "/api/item/refreshPalette" => match restage(lib, s(args, "id").unwrap_or(""), |root, item| {
926            if falcon_library::media::is_audio(&item.ext) {
927                return Ok(None);
928            }
929            let img = palette::refresh_source(item, root).ok_or("nothing to analyse")?;
930            Ok(Some(Item { palettes: palette::extract(&img), ..item.clone() }))
931        }) {
932            Restaged::NoItem => Reply::Fail("File does not exist.".into()),
933            _ => Reply::Empty,
934        },
935        "/api/item/refreshThumbnail" | "/api/v2/item/refreshThumbnail" => {
936            let v2 = path.starts_with("/api/v2/");
937            let id = if v2 { s(args, "itemId") } else { s(args, "id") };
938            let done = restage(lib, id.unwrap_or(""), |root, item| {
939                // As in the app, a custom thumbnail is left alone.
940                if thumbnail::has_custom_thumbnail(item) {
941                    return Ok(None);
942                }
943                thumbnail::redrawn(root, item.clone()).map(Some).map_err(|e| e.to_string())
944            });
945            match done {
946                Restaged::NoItem if v2 => Reply::Data(json!(false)),
947                Restaged::NoItem => Reply::Fail("File does not exist.".into()),
948                _ if v2 => Reply::Data(json!(true)),
949                _ => Reply::Empty,
950            }
951        }
952        _ => return None,
953    })
954}
955
956enum Restaged {
957    NoItem,
958    Failed,
959    Done,
960}
961
962/// Runs `work` on a copy of item `id` without the library lock, then
963/// saves what it changed. `work` returns `None` when there is nothing to
964/// save. Only the keys `work` changed are written over the item as it is
965/// at commit time, so edits made meanwhile (by the app, say) survive.
966fn restage(lib: &Shared, id: &str, work: impl FnOnce(&Path, &Item) -> Result<Option<Item>, String>) -> Restaged {
967    let (root, before) = {
968        let lib = lock(lib);
969        match lib.item(id) {
970            Some(i) => (lib.root().to_path_buf(), i.clone()),
971            None => return Restaged::NoItem,
972        }
973    };
974    let after = match work(&root, &before) {
975        Ok(Some(after)) => after,
976        Ok(None) => return Restaged::Done,
977        Err(_) => return Restaged::Failed,
978    };
979    let mut lib = lock(lib);
980    // A library switched meanwhile has other items, even under the same id.
981    if lib.root() != root {
982        return Restaged::NoItem;
983    }
984    let Some(current) = lib.item(id).cloned() else { return Restaged::NoItem };
985    let Some(merged) = merge_changes(&before, &after, &current) else { return Restaged::Failed };
986    match lib.update_item(merged) {
987        Ok(()) => Restaged::Done,
988        Err(_) => Restaged::Failed,
989    }
990}
991
992/// `current` with the keys that differ between `before` and `after` taken
993/// from `after` (and the keys `after` dropped removed). Key order is kept.
994fn merge_changes(before: &Item, after: &Item, current: &Item) -> Option<Item> {
995    let (Value::Object(b), Value::Object(a), Value::Object(mut c)) =
996        (serde_json::to_value(before).ok()?, serde_json::to_value(after).ok()?, serde_json::to_value(current).ok()?)
997    else {
998        return None;
999    };
1000    for (k, v) in &a {
1001        if b.get(k) != Some(v) {
1002            c.insert(k.clone(), v.clone());
1003        }
1004    }
1005    for k in b.keys().filter(|k| !a.contains_key(*k)) {
1006        c.shift_remove(k);
1007    }
1008    serde_json::from_value(Value::Object(c)).ok()
1009}
1010
1011/// v1's URL routes put the tags they were given at the front of the
1012/// recent tags, as Eagle's do (its path routes don't).
1013fn record_tags(lib: &Shared, entry: &Map<String, Value>) {
1014    let tags: Vec<String> = strings(entry, "tags").into_iter().filter(|t| !t.is_empty()).collect();
1015    if !tags.is_empty() {
1016        let _ = lock(lib).record_tag_use(&tags);
1017    }
1018}
1019
1020fn folder_reply(lib: &Library, id: &str) -> Reply {
1021    match lib.all_folders().into_iter().find(|f| f.id == id) {
1022        Some(f) => Reply::Data(folder_json(f)),
1023        None => Reply::Fail("Folder does not exist.".into()),
1024    }
1025}
1026
1027/// `/api/item/thumbnail`: the original when there is no thumbnail, else the
1028/// thumbnail, URL-encoded the way Eagle does.
1029fn thumbnail_path(lib: &Library, item: &Item) -> String {
1030    let path = match item.thumbnail_path(lib.root()) {
1031        Some(t) => t,
1032        None => item.original_path(lib.root()),
1033    };
1034    path.display().to_string().replace(' ', "%20").replace('#', "%23")
1035}
1036
1037/// Imports a file with the metadata Eagle's add routes accept.
1038///
1039/// The library is locked twice, briefly: to look up the folders, then to
1040/// commit. The source is read, thumbnailed and analysed in between, unlocked,
1041/// because the app locks the same library every frame and a dataless iCloud
1042/// source blocks until it has downloaded.
1043///
1044/// With `auto_tags`, the auto-tags of the target folders come first, then
1045/// the given tags, as Eagle's URL imports do (its path imports add none).
1046fn add_path(lib: &Shared, path: PathBuf, item: &Map<String, Value>, outer: &Map<String, Value>, auto_tags: bool) -> Result<String, String> {
1047    let (folders, tags) = {
1048        let lib = lock(lib);
1049        let mut folders = folder_ids(&lib, item);
1050        if folders.is_empty() {
1051            folders = folder_ids(&lib, outer);
1052        }
1053        let mut tags = if auto_tags { falcon_library::autotag::auto_tags_for(&lib.metadata.folders, &folders) } else { Vec::new() };
1054        falcon_library::autotag::append_new(&mut tags, &strings(item, "tags"));
1055        (folders, tags)
1056    };
1057    let opts = ImportOptions {
1058        folders,
1059        tags,
1060        palette: true,
1061        annotation: s(item, "annotation").unwrap_or("").to_string(),
1062        url: s(item, "website").unwrap_or("").to_string(),
1063        ..Default::default()
1064    };
1065    let staged = falcon_library::import::stage(&path, &opts).map_err(|e| e.to_string())?;
1066    let mut lib = lock(lib);
1067    let id = lib.commit_import(staged).map_err(|e| e.to_string())?;
1068    let mut changed = lib.item(&id).cloned().ok_or("import failed")?;
1069    let mut dirty = false;
1070    if let Some(name) = s(item, "name") {
1071        changed.name = name.chars().take(128).collect();
1072        dirty = true;
1073    }
1074    if let Some(star) = number(item, "star").filter(|s| (1..=5).contains(s)) {
1075        changed.extra.insert("star".into(), json!(star));
1076        dirty = true;
1077    }
1078    if let Some(t) = number(item, "modificationTime") {
1079        changed.modification_time = t;
1080        dirty = true;
1081    }
1082    if dirty {
1083        lib.update_item(changed).map_err(|e| e.to_string())?;
1084    }
1085    Ok(id)
1086}
1087
1088/// Downloads an http(s) or data URL to a temporary file and imports it,
1089/// all without holding the library lock until the commit.
1090fn add_url(lib: &Shared, url: &str, item: &Map<String, Value>, outer: &Map<String, Value>) -> Result<String, String> {
1091    let (bytes, ext) = if let Some(rest) = url.strip_prefix("data:") {
1092        let (meta, data) = rest.split_once(',').ok_or("Invalid data URL.")?;
1093        let ext = meta.split(';').next().and_then(|m| m.split('/').nth(1)).unwrap_or("png");
1094        let ext = if ext == "svg+xml" { "svg" } else if ext == "jpeg" { "jpg" } else { ext };
1095        let bytes = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
1096        (bytes, ext.to_string())
1097    } else if url.starts_with("http://") || url.starts_with("https://") {
1098        let mut resp = ureq::get(url).call().map_err(|e| e.to_string())?;
1099        let bytes = resp.body_mut().read_to_vec().map_err(|e| e.to_string())?;
1100        let path = url.split(['?', '#']).next().unwrap_or(url);
1101        let ext = path.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map_or("jpg", |(_, e)| e).to_lowercase();
1102        (bytes, ext)
1103    } else {
1104        return Err("Unsupported URL.".into());
1105    };
1106    let name = s(item, "name").map(String::from).unwrap_or_else(|| {
1107        url.split(['?', '#']).next().and_then(|p| p.rsplit('/').next()).and_then(|f| f.rsplit_once('.')).map_or("image".into(), |(n, _)| n.to_string())
1108    });
1109    let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
1110    let file = dir.path().join(format!("{name}.{ext}"));
1111    std::fs::write(&file, bytes).map_err(|e| e.to_string())?;
1112    let mut entry = item.clone();
1113    entry.entry("website").or_insert_with(|| json!(if url.starts_with("data:") { "" } else { url }));
1114    add_path(lib, file, &entry, outer, true)
1115}
1116
1117/// `/api/item/list` with Eagle's quirks: `offset` is a page number, no limit
1118/// means 200, and the page is re-sorted newest first after being cut.
1119fn v1_list(lib: &Library, args: &Map<String, Value>) -> Vec<Value> {
1120    let names = strings(args, "keyword").into_iter().chain(strings(args, "name")).map(|k| k.to_lowercase()).collect::<Vec<_>>();
1121    let urls = strings(args, "url");
1122    let tags = strings(args, "tags");
1123    let folders = strings(args, "folders");
1124    let ext = s(args, "ext").map(str::to_lowercase);
1125    let mut items: Vec<&Item> = lib
1126        .items()
1127        .iter()
1128        .filter(|i| !i.is_deleted)
1129        .filter(|i| names.iter().all(|n| i.name.to_lowercase().contains(n)))
1130        .filter(|i| ext.as_ref().is_none_or(|e| i.ext == *e))
1131        .filter(|i| urls.iter().all(|u| i.url.contains(u.as_str())))
1132        .filter(|i| tags.is_empty() || i.tags.iter().any(|t| tags.contains(t)))
1133        .filter(|i| folders.is_empty() || i.folders.iter().any(|f| folders.contains(f)))
1134        .collect();
1135
1136    sort_v1(&mut items, s(args, "orderBy").unwrap_or("CREATEDATE"));
1137    let limit = number(args, "limit").map_or(200, |l| l as usize);
1138    let page = number(args, "offset").unwrap_or(0) as usize;
1139    let mut page: Vec<&Item> = items.into_iter().skip(limit * page).take(limit).collect();
1140    page.sort_by_key(|i| std::cmp::Reverse(i.modification_time));
1141    page.into_iter().map(item_json).collect()
1142}
1143
1144/// Sorts by an Eagle `orderBy` key (`CREATEDATE`, `FILESIZE`, `NAME`,
1145/// `RESOLUTION`); a `-` reverses it.
1146fn sort_v1(items: &mut [&Item], order: &str) {
1147    let reverse = order.contains('-');
1148    let key = order.replace('-', "").to_uppercase();
1149    match key.as_str() {
1150        "FILESIZE" => items.sort_by_key(|i| i.size),
1151        "NAME" => items.sort_by(|a, b| search::natural_cmp(&a.name, &b.name)),
1152        "RESOLUTION" => items.sort_by_key(|i| i.width * i.height),
1153        _ => items.sort_by_key(|i| i.modification_time),
1154    }
1155    if reverse {
1156        items.reverse();
1157    }
1158}
1159
1160/// `/api/v2/item/get` filters, AND-ed; trash excluded; newest first.
1161/// `only`, from `smartFolders`, keeps the items of those smart folders.
1162fn v2_get(lib: &Library, args: &Map<String, Value>, only: Option<&std::collections::HashSet<String>>) -> Vec<Value> {
1163    let id = s(args, "id");
1164    let ids = strings(args, "ids");
1165    let keywords: Vec<String> = strings(args, "keywords").into_iter().map(|k| k.to_lowercase()).collect();
1166    let tags = strings(args, "tags");
1167    let folders = strings(args, "folders");
1168    let ext = s(args, "ext").map(str::to_lowercase);
1169    let annotation = s(args, "annotation").map(str::to_lowercase);
1170    let url = s(args, "url");
1171    let rating = number(args, "rating");
1172    let flag = |k: &str| matches!(args.get(k), Some(Value::Bool(true))) || s(args, k) == Some("true");
1173    let mut items: Vec<&Item> = lib
1174        .items()
1175        .iter()
1176        .filter(|i| !i.is_deleted)
1177        .filter(|i| id.is_none_or(|x| i.id == x))
1178        .filter(|i| only.is_none_or(|o| o.contains(&i.id)))
1179        .filter(|i| ids.is_empty() || ids.contains(&i.id))
1180        .filter(|i| !flag("isUnfiled") || i.folders.is_empty())
1181        .filter(|i| !flag("isUntagged") || i.tags.is_empty())
1182        .filter(|i| keywords.iter().all(|k| i.name.to_lowercase().contains(k)))
1183        .filter(|i| tags.iter().all(|t| i.tags.contains(t)))
1184        .filter(|i| folders.iter().all(|f| i.folders.contains(f)))
1185        .filter(|i| ext.as_ref().is_none_or(|e| i.ext == *e))
1186        .filter(|i| annotation.as_ref().is_none_or(|a| i.annotation.to_lowercase().contains(a)))
1187        .filter(|i| url.is_none_or(|u| i.url.contains(u)))
1188        .filter(|i| rating.is_none_or(|r| i.extra.get("star").and_then(Value::as_u64).unwrap_or(0) == r))
1189        .collect();
1190    items.sort_by_key(|i| std::cmp::Reverse(i.modification_time));
1191    items.into_iter().map(item_json).collect()
1192}
1193
1194/// Keeps only `fields`, when given (the v2 projection).
1195fn project(items: Vec<Value>, args: &Map<String, Value>) -> Vec<Value> {
1196    let fields = strings(args, "fields");
1197    if fields.is_empty() {
1198        return items;
1199    }
1200    items
1201        .into_iter()
1202        .map(|v| match v {
1203            Value::Object(m) => Value::Object(m.into_iter().filter(|(k, _)| fields.contains(k)).collect()),
1204            other => other,
1205        })
1206        .collect()
1207}
1208
1209/// Eagle's access rule: a request whose `Host` is the server's own address
1210/// is served; otherwise it must carry the Developer token, compared
1211/// exactly. An empty Developer token (Eagle's default; one that is only
1212/// whitespace counts as empty) means token auth is off: only the Host
1213/// check applies, and no token given, not even an empty one, lets a
1214/// request in.
1215fn authorized(host_ok: bool, token: &str, given: Option<&str>) -> bool {
1216    host_ok || (!token.trim().is_empty() && given == Some(token))
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222
1223    #[test]
1224    fn query_strings_decode() {
1225        let m = parse_query("id=AB%20C&tags=a%2Cb&x=1+2");
1226        assert_eq!(m["id"], "AB C");
1227        assert_eq!(m["tags"], "a,b");
1228        assert_eq!(m["x"], "1 2");
1229        assert_eq!(decode("100%"), "100%");
1230    }
1231
1232    #[test]
1233    fn empty_token_means_host_check_only() {
1234        assert!(authorized(true, "", None));
1235        assert!(authorized(true, "", Some("")));
1236        assert!(authorized(true, "abc", Some("wrong")));
1237        assert!(!authorized(false, "", None));
1238        assert!(!authorized(false, "", Some("")));
1239        assert!(!authorized(false, "", Some("abc")));
1240        assert!(!authorized(false, "  ", Some("  ")), "a blank token is no token");
1241    }
1242
1243    #[test]
1244    fn set_token_is_exact() {
1245        assert!(authorized(false, "abc", Some("abc")));
1246        assert!(!authorized(false, "abc", Some("ABC")));
1247        assert!(!authorized(false, "abc", Some("abc ")));
1248        assert!(!authorized(false, "abc", Some("")));
1249        assert!(!authorized(false, "abc", None));
1250    }
1251}