// Copyright © 2020 Jakob L. Kreuze // // This file is part of бирка-тян. // // бирка-тян is free software; you can redistribute it and/or modify it // under the terms of the GNU Affero General Public License as published // by the Free Software Foundation; either version 3 of the License, or // (at your option) any later version. // // бирка-тян is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with бирка-тян. If not, see . #![feature(proc_macro_hygiene, decl_macro)] #[macro_use] extern crate anyhow; extern crate image; #[macro_use] extern crate rocket; extern crate rocket_contrib; #[macro_use] extern crate serde_derive; mod database; use anyhow::Result; use database::{hash_file, Query, TagDatabase}; use image::ImageFormat; use rocket::{Data, State}; use rocket_contrib::json::Json; use rocket_contrib::serve::StaticFiles; use rocket_contrib::templates::Template; use std::env; use std::fs; use std::fs::File; use std::os::unix::fs::symlink; use std::path::Path; use std::sync::Mutex; /// Maximum number of results returned by any API endpoint. const RESULTS_PER_QUERY: i64 = 50; /// Shorthand for the state that is passed to all handlers. type SiteState<'a> = State<'a, Mutex>; /// Collection of data necessary to serve images from the tag database. struct DatabaseConnection { tb: TagDatabase, image_dir: String, } impl DatabaseConnection { fn new() -> Result { let bin = env::current_exe()?.canonicalize()?; let bin_dir = bin.parent().unwrap(); let image_dir = String::from(bin_dir.join("image/").to_str().unwrap()); let tb_path = bin_dir.join("birka.db"); let tb = TagDatabase::new(tb_path.to_str().unwrap())?; Ok(DatabaseConnection { tb, image_dir }) } } /// Information about an image, as returned by the 'posts' endpoint. #[derive(Serialize)] struct ImageResult { id: i64, filename: String, thumb_filename: String, tags: Vec, } /// Return the file name of the thumbnail for the image at `filename`. fn thumb_filename(filename: &str) -> String { String::from(format!( "{}_thumb.png", // Strip extension. if let Some(n) = filename.rfind('.') { &filename[..n] } else { &filename[..] } )) } #[get("/posts?&")] fn get_posts(conn: SiteState, tags: String, last: Option) -> Json> { let tb = &conn.inner().lock().unwrap().tb; // Ensure that an empty vector is passed to `tb.query` if no tags were // specified. let tags = if !tags.is_empty() { tags.split(",").collect::>() } else { vec![] }; let results = tb .query(Query { tags: &tags[..], last_id: last, limit: Some(RESULTS_PER_QUERY), }) .unwrap(); Json( results .iter() .map(|image| ImageResult { id: image.id, filename: image.filename.clone(), thumb_filename: thumb_filename(&image.filename), tags: tb.tags_for_image(image.id).unwrap(), }) .collect(), ) } #[post("/posts/", data = "")] fn put_posts(conn: SiteState, filename: String, data: Data) -> Json<&str> { let conn = conn.inner().lock().unwrap(); let path = Path::new(&conn.image_dir).join(filename); data.stream_to_file(&path).unwrap(); conn.tb .import_image(&path, &hash_file(&path).unwrap()) .unwrap(); Json("uploaded!") } #[get("/")] fn index(conn: SiteState) -> Template { display_posts(conn, String::from(""), None) } #[get("/posts?&")] fn display_posts(conn: SiteState, tags: String, last: Option) -> Template { #[derive(Serialize)] struct TagResult { name: String, count: i64, } #[derive(Serialize)] struct Context { tags: Vec, images: Vec, next_page: String, } let base_params = format!("/posts?tags={}", tags); let tag_results = { // This is done in a block because `tb` needs to go out of scope before // calling out to `get_posts` to perform the query. Otherwise, we // deadlock because the mutex is locked for the entirety of // `display_posts`. let tb = &conn.inner().lock().unwrap().tb; tb.top_tags(20) .unwrap() .iter() .map(|tup| TagResult { name: tup.1.clone(), count: tup.0, }) .collect() }; let images = get_posts(conn, tags, last).into_inner(); let context = Context { tags: tag_results, next_page: if images.len() as i64 == RESULTS_PER_QUERY { let last_image = images.last().unwrap(); format!("{}&last={}", base_params, last_image.id) } else { String::from("") }, images, }; Template::render("index", context) } /// Maybe initialize the directory for storing image symlinks and thumbnails. fn create_image_directory(conn: &DatabaseConnection) { fs::create_dir(&conn.image_dir).ok(); for image in conn.tb.images_by_tags(&vec![][..]).unwrap() { let src = Path::new(&image.orig_dir).join(&image.filename); let dst = Path::new(&conn.image_dir).join(&image.filename); symlink(&src, &dst).ok(); let stem = dst.file_stem().unwrap().to_str().unwrap(); let thumb_path = Path::new(&conn.image_dir).join(&thumb_filename(&stem)); if !thumb_path.exists() { let im = image::open(&Path::new(&src)).unwrap(); let out = &mut File::create(&thumb_path).unwrap(); im.thumbnail(100, 100) .write_to(out, ImageFormat::Png) .unwrap(); } } } fn main() { let conn = DatabaseConnection::new().unwrap(); create_image_directory(&conn); rocket::ignite() .mount("/public", StaticFiles::from("./static/")) .mount("/image", StaticFiles::from(&conn.image_dir)) .mount("/", routes![index, display_posts]) .mount("/api", routes![get_posts, put_posts]) .attach(Template::fairing()) .manage(Mutex::new(conn)) .launch(); }