1mod 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
41pub const EAGLE_PORT: u16 = 41595;
43pub const DEFAULT_PORT: u16 = 41596;
44
45const API_VERSION: &str = "4.0.0";
47
48pub type Shared = Arc<Mutex<Library>>;
49
50struct State {
54 history: Mutex<Vec<String>>,
55 token: Mutex<String>,
56 preferences: Mutex<Value>,
57 preferences_changed: AtomicBool,
59 unlocked: Mutex<HashSet<(String, String)>>,
61 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 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 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
105fn default_preferences() -> Value {
108 json!({"general": {"showCollectModal": "false"}, "developer": {"apiToken": ""}})
109}
110
111pub 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
125pub struct Handle {
127 server: Arc<Server>,
128 thread: Option<std::thread::JoinHandle<()>>,
129 slot: Arc<Mutex<Shared>>,
131 state: Arc<State>,
132 pub port: u16,
133}
134
135impl Handle {
136 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 pub fn library(&self) -> Shared {
146 self.slot.lock().unwrap_or_else(|p| p.into_inner()).clone()
147 }
148
149 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 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 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 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 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
202pub 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 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
241enum Reply {
243 Data(Value),
244 Empty,
245 Fail(String),
247 Error(String),
250 BadRequest,
251 NotFound,
252 WrongMethod,
253 Unauthorized,
254 Redirect(String),
256 Missing(&'static str),
258 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 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
301fn 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
317const 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
394fn 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
417fn 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
439fn folder_json(f: &Folder) -> Value {
441 folder_json_with(f, &HashSet::new())
442}
443
444fn 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
495fn 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 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 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 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 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
822fn lock(lib: &Shared) -> MutexGuard<'_, Library> {
824 lib.lock().unwrap_or_else(|p| p.into_inner())
825}
826
827fn 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
886fn 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 "/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 "/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 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
962fn 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 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, ¤t) else { return Restaged::Failed };
986 match lib.update_item(merged) {
987 Ok(()) => Restaged::Done,
988 Err(_) => Restaged::Failed,
989 }
990}
991
992fn 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
1011fn 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
1027fn 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
1037fn 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
1088fn 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
1117fn 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
1144fn 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
1160fn 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
1194fn 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
1209fn 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}