diff options
Diffstat (limited to 'src/birka-web.rs')
| -rw-r--r-- | src/birka-web.rs | 303 |
1 files changed, 303 insertions, 0 deletions
diff --git a/src/birka-web.rs b/src/birka-web.rs new file mode 100644 index 0000000..22ace80 --- /dev/null +++ b/src/birka-web.rs @@ -0,0 +1,303 @@ +// Copyright © 2020 Jakob L. Kreuze <zerodaysfordays@sdf.org> +// +// 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 <http://www.gnu.org/licenses/>. + +#![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 rusqlite; +#[macro_use] +extern crate serde_derive; + +mod database; + +use anyhow::Result; +use database::{Image, Query, TagDatabase}; +use image::ImageFormat; +use multipart::{MultipartFormData, MultipartFormDataField, MultipartFormDataOptions}; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use rocket::http::ContentType; +use rocket::{Data, State}; +use rocket_contrib::json::Json; +use rocket_contrib::serve::StaticFiles; +use rocket_contrib::templates::Template; + +use std::fs; +use std::fs::File; +use std::io::Write; +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<DatabaseConnection>>; + +/// Everything necessary to serve images from the tag database. +struct DatabaseConnection { + tb: TagDatabase, + image_dir: String, +} + +impl DatabaseConnection { + fn new() -> Result<Self> { + // FIXME: It is somewhat of an arbitrary choice that the directory + // containing symbolic links and thumbnails be relative to the binary. + let bin = std::env::current_exe()?.canonicalize()?; + let bin_dir = bin.parent().unwrap(); + let image_dir = bin_dir.join("image/").to_str().unwrap().to_string(); + let tb = TagDatabase::new(&bin_dir.join("birka.db"))?; + Ok(DatabaseConnection { tb, image_dir }) + } +} + +/// Information about an image, as returned by the 'posts' endpoint. +#[derive(Debug, Serialize)] +struct ImageResult { + id: i64, + filename: String, + thumb_filename: String, + orig_filename: String, + tags: Vec<String>, +} + +/// Return the store file name of the image at `filename`. +fn image_store_filename(id: i64, filename: &str) -> String { + if let Some(n) = filename.rfind('.') { + format!("{id}.{ext}", id = id, ext = &filename[n + 1..]) + } else { + id.to_string() + } +} + +/// Return the store file name of the thumbnail for the image at `filename`. +fn thumb_filename(id: i64, filename: &str) -> String { + if let Some(n) = filename.rfind('.') { + format!("{id}_thumb.{ext}", id = id, ext = &filename[n + 1..]) + } else { + format!("{id}_thumb", id = id) + } +} + +#[get("/posts?<tags>&<last>")] +fn get_posts(conn: SiteState, tags: String, last: Option<i64>) -> Json<Vec<ImageResult>> { + 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::<Vec<&str>>() + } else { + vec![] + }; + + let results = tb + .query(Query::new(&tags[..], last, Some(RESULTS_PER_QUERY))) + .unwrap(); + + Json( + results + .iter() + .map(|image| ImageResult { + id: image.id, + filename: image_store_filename(image.id, &image.filename), + thumb_filename: thumb_filename(image.id, &image.filename), + orig_filename: image.filename.clone(), + tags: tb.tags_for_image(image.id).unwrap(), + }) + .collect(), + ) +} + +#[get("/posts/<id>")] +fn get_post(conn: SiteState, id: i64) -> Json<ImageResult> { + let tb = &conn.inner().lock().unwrap().tb; + let image = tb.image_by_id(id).unwrap(); + + Json(ImageResult { + id: image.id, + filename: image_store_filename(image.id, &image.filename), + thumb_filename: thumb_filename(image.id, &image.filename), + orig_filename: image.filename.clone(), + tags: tb.tags_for_image(image.id).unwrap(), + }) +} + +#[post("/posts", data = "<data>")] +fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Json<String> { + let conn = conn.inner().lock().unwrap(); + let options = MultipartFormDataOptions::with_multipart_form_data_fields(vec![ + MultipartFormDataField::text("tags"), + MultipartFormDataField::raw("image") + .size_limit(32 * 1024 * 1024) + .content_type_by_string(Some(multipart::mime::IMAGE_STAR)) + .unwrap(), + ]); + + let mut data = MultipartFormData::parse(content_type, data, options).unwrap(); + let raw = data.raw.remove("image").unwrap().remove(0); + + let filename = raw + .file_name + .unwrap_or(thread_rng().sample_iter(&Alphanumeric).take(30).collect()); + let path = Path::new(&conn.image_dir).join(&filename); + let mut f = File::create(&path).unwrap(); + f.write_all(&raw.raw).unwrap(); + + let hash = database::hash_file(&path).unwrap(); + let id = conn.tb.import_image(&path, &hash).unwrap(); + add_to_store( + &Image { + id, + blake2: hash, + filename: filename.clone(), + orig_dir: conn.image_dir.clone(), + }, + &conn.image_dir, + ); + + let tags = data.texts.remove("tags").unwrap().remove(0).text; + for tag in tags.split(",") { + conn.tb.add_tag(tag).unwrap(); + conn.tb.tag_image(id, tag).unwrap(); + } + + Json("Uploaded!".into()) +} + +#[get("/")] +fn index(conn: SiteState) -> Template { + display_posts(conn, String::from(""), None) +} + +#[get("/upload")] +fn upload() -> Template { + Template::render("upload", None::<()>) +} + +#[get("/posts?<tags>&<last>")] +fn display_posts(conn: SiteState, tags: String, last: Option<i64>) -> Template { + #[derive(Serialize)] + struct TagResult { + name: String, + count: i64, + } + + #[derive(Serialize)] + struct Context { + tags: Vec<TagResult>, + images: Vec<ImageResult>, + 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) +} + +#[get("/posts/<id>")] +fn display_post(conn: SiteState, id: i64) -> Template { + #[derive(Serialize)] + struct Context { + tags: Vec<String>, + filename: String, + orig_filename: String, + } + + let tb = &conn.inner().lock().unwrap().tb; + let image = tb.image_by_id(id).unwrap(); + + Template::render( + "image", + Context { + tags: tb.tags_for_image(image.id).unwrap(), + filename: image_store_filename(image.id, &image.filename), + orig_filename: image.filename, + }, + ) +} + +/// Create a thumbnail for `image` in the store at `image_dir`. +fn add_to_store(image: &Image, image_dir: &str) { + let orig_path = Path::new(&image.orig_dir).join(&image.filename); + let store_path = image_store_filename(image.id, &image.filename); + let store_path = Path::new(image_dir).join(store_path); + symlink(orig_path, &store_path).ok(); + let thumb_path = thumb_filename(image.id, &image.filename); + let thumb_path = Path::new(image_dir).join(&thumb_path); + if !thumb_path.exists() { + let im = image::open(store_path).unwrap(); + let out = &mut File::create(&thumb_path).unwrap(); + im.thumbnail(100, 100) + .write_to(out, ImageFormat::Png) + .unwrap(); + } +} + +/// 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() { + add_to_store(&image, &conn.image_dir); + } +} + +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, upload, display_posts, display_post]) + .mount("/api", routes![get_posts, get_post, put_post]) + .attach(Template::fairing()) + .manage(Mutex::new(conn)) + .launch(); +} |