diff options
| author | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2020-07-26 19:23:37 -0400 |
|---|---|---|
| committer | Jakob L. Kreuze <zerodaysfordays@sdf.org> | 2020-07-26 19:23:37 -0400 |
| commit | b311644824bc0bff9603fea69d979fa6bb4b97e0 (patch) | |
| tree | 221b0b6a5f1a90f953ecf03445fe6aed17ffee6b /src/kona-web.rs | |
| parent | 45b48fd9b9ee21759701d3d4f445d8bf141d3f9e (diff) | |
Rebrand (again).
Diffstat (limited to 'src/kona-web.rs')
| -rw-r--r-- | src/kona-web.rs | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/src/kona-web.rs b/src/kona-web.rs new file mode 100644 index 0000000..2d63772 --- /dev/null +++ b/src/kona-web.rs @@ -0,0 +1,406 @@ +// Copyright © 2020 Jakob L. Kreuze <zerodaysfordays@sdf.org> +// +// This file is part of Kona. +// +// Kona 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. +// +// Kona 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 Kona. 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::{Keyset, TagDatabase}; +use image::ImageFormat; +use multipart::{MultipartFormData, MultipartFormDataField, MultipartFormDataOptions}; +use rand::distributions::Alphanumeric; +use rand::{thread_rng, Rng}; +use rocket::http::ContentType; +use rocket::request::Form; +use rocket::{Data, State}; +use rocket_contrib::json::Json; +use rocket_contrib::serve::StaticFiles; +use rocket_contrib::templates::Template; + +use std::convert::AsRef; +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 files from the tag database. +struct DatabaseConnection { + tb: TagDatabase, + file_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 file_dir = bin_dir.join("files/").to_str().unwrap().to_string(); + let tb = TagDatabase::new(&bin_dir.join("kona.db"))?; + Ok(DatabaseConnection { tb, file_dir }) + } +} + +/// Information about a file, as returned by the 'posts' endpoint. +#[derive(Debug, Serialize)] +struct FileResult { + id: i64, + filename: String, + thumb_filename: String, + orig_filename: String, + tags: Vec<String>, +} + +/// Return whether or not `filename` ends with one of `extensions`. +fn has_extension<T: AsRef<Path>>(filename: T, extensions: Vec<&str>) -> bool { + filename + .as_ref() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| extensions.contains(&ext)) + .unwrap_or(false) +} + +/// Return whether or not `filename` ends with the extension of a known video file type. +fn is_video<T: AsRef<Path>>(filename: T) -> bool { + has_extension(filename, vec!["mp4", "mkv", "webm"]) +} + +/// Return whether or not `filename` ends with the extension of a known image file type. +fn is_image<T: AsRef<Path>>(filename: T) -> bool { + has_extension(filename, vec!["png", "jpg", "jpeg", "gif"]) +} + +/// Return the store file name of `filename`. +fn store_filename<T: AsRef<Path>>(id: i64, path: T) -> String { + if let Some(filename) = path.as_ref().file_name() { + let filename = filename.to_string_lossy(); + if let Some(n) = filename.rfind('.') { + format!("{id}.{ext}", id = id, ext = &filename[n + 1..]) + } else { + id.to_string() + } + } else { + id.to_string() + } +} + +/// Return the store file name of the thumbnail of `filename`. +fn thumb_filename<T: AsRef<Path>>(id: i64, path: T) -> String { + if let Some(ext) = path.as_ref().extension() { + if let Some(ext) = ext.to_str() { + let ext = if is_video(path.as_ref()) { "png" } else { ext }; + format!("{id}_thumb.{ext}", id = id, ext = ext) + } else { + format!("{}_thumb", id) + } + } else { + format!("{}_thumb", id) + } +} + +#[get("/posts?<query>&<last>")] +fn get_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Json<Vec<FileResult>>> { + let tb = &conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))? + .tb; + + let keyset = if let Some(id) = last { + Keyset { + last_id: id, + how_many: RESULTS_PER_QUERY, + } + } else { + Keyset { + last_id: i64::MAX, + how_many: RESULTS_PER_QUERY, + } + }; + let results = tb.query(&database::parse_query(query)?, Some(keyset))?; + + Ok(Json( + results + .iter() + .map(|file| FileResult { + id: file.id, + filename: store_filename(file.id, &file.path), + thumb_filename: thumb_filename(file.id, &file.path), + orig_filename: file.path.clone(), + tags: file.tags.clone(), + }) + .collect(), + )) +} + +#[get("/posts/<id>")] +fn get_post(conn: SiteState, id: i64) -> Result<Json<FileResult>> { + let tb = &conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))? + .tb; + let file = tb.file_by_id(id)?; + + Ok(Json(FileResult { + id: file.id, + filename: store_filename(file.id, &file.path), + thumb_filename: thumb_filename(file.id, &file.path), + orig_filename: file.path.clone(), + tags: file.tags, + })) +} + +#[derive(FromForm)] +struct UpdateTags { + tags: String, +} + +#[post("/posts/<id>", data = "<form>")] +fn update_post(conn: SiteState, id: i64, form: Form<UpdateTags>) -> Result<Json<String>> { + let tb = &conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))? + .tb; + tb.tag_file(id, &form.tags.split(',').collect::<Vec<_>>()[..])?; + Ok(Json(String::from("Updated!"))) +} + +#[post("/posts", data = "<data>")] +fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Result<Json<String>> { + let conn = conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))?; + 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))?, + ]); + + let mut data = MultipartFormData::parse(content_type, data, options)?; + let raw = if let Some(mut data) = data.raw.remove("image") { + data.remove(0) + } else { + bail!("No `image` field.") + }; + + let filename = raw + .file_name + .unwrap_or_else(|| thread_rng().sample_iter(&Alphanumeric).take(30).collect()); + let path = Path::new(&conn.file_dir).join(&filename); + let mut f = File::create(&path)?; + f.write_all(&raw.raw)?; + + let tags = if let Some(mut vec) = data.texts.remove("tags") { + let text = vec.remove(0).text; + text.split(',').map(String::from).collect() + } else { + vec![] + }; + let file = conn.tb.add_file(&path, &tags)?; + add_to_store(&file, &conn.file_dir); + + Ok(Json("Uploaded!".into())) +} + +#[get("/")] +fn index(conn: SiteState) -> Result<Template> { + display_posts(conn, String::from(""), None) +} + +#[get("/upload")] +fn upload() -> Template { + Template::render("upload", None::<()>) +} + +#[get("/posts?<query>&<last>")] +fn display_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Template> { + #[derive(Serialize)] + struct TagResult { + name: String, + count: i64, + } + + #[derive(Serialize)] + struct Context { + tags: Vec<TagResult>, + images: Vec<FileResult>, + next_page: String, + } + + let base_params = format!("/posts?query={}", query); + 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() + .map_err(|_| anyhow!("Could not lock database."))? + .tb; + tb.top_tags(Some(20))? + .iter() + .map(|tup| TagResult { + name: tup.1.clone(), + count: tup.0, + }) + .collect() + }; + let images = get_posts(conn, query, 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, + }; + Ok(Template::render("index", context)) +} + +#[get("/posts/<id>")] +fn display_post(conn: SiteState, id: i64) -> Result<Template> { + #[derive(Serialize)] + struct Context { + id: i64, + tags: Vec<String>, + filename: String, + orig_filename: String, + is_image: bool, + is_video: bool, + is_unknown: bool, + } + + let tb = &conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))? + .tb; + let file = tb.file_by_id(id)?; + + let orig_filename = String::from( + Path::new(&file.path) + .file_name() + .ok_or_else(|| anyhow!("Directory indexed and returned by query"))? + .to_str() + .ok_or_else(|| anyhow!("Could not parse filename"))?, + ); + + let is_image = is_image(&file.path); + let is_video = is_video(&file.path); + + Ok(Template::render( + "image", + Context { + id, + orig_filename, + tags: file.tags, + filename: store_filename(file.id, &file.path), + is_image, + is_video, + is_unknown: !is_image && !is_video, + }, + )) +} + +/// Create a thumbnail for `file` in the store at `file_dir`. +fn add_to_store(file: &database::File, file_dir: &str) { + let store_path = store_filename(file.id, &file.path); + let store_path = Path::new(file_dir).join(store_path); + symlink(&file.path, &store_path).ok(); + if !store_path.exists() { + panic!("could not create symlink {:?}!", store_path); + } + + let thumb_path = thumb_filename(file.id, &file.path); + let thumb_path = Path::new(file_dir).join(&thumb_path); + if !thumb_path.exists() { + if let Ok(im) = image::open(&store_path) { + let out = &mut File::create(&thumb_path).unwrap(); + im.thumbnail(100, 100) + .write_to(out, ImageFormat::Png) + .unwrap(); + } else if is_video(&file.path) { + // FIXME: Can we do this... natively? + std::process::Command::new("ffmpeg") + .arg("-i") + .arg(&store_path) + .arg("-vframes") + .arg("1") + .arg("-s") + .arg("100x100") + .arg(&thumb_path) + .output() + .expect("failed to execute process"); + } else { + let src = Path::new("./static/img/missing_thumb.png"); + symlink(src.canonicalize().unwrap(), &thumb_path).unwrap(); + if !thumb_path.exists() { + panic!("could not create symlink {:?}!", thumb_path); + } + } + } +} + +/// Maybe initialize the directory for storing image symlinks and thumbnails. +fn create_image_directory(conn: &DatabaseConnection) { + fs::create_dir(&conn.file_dir).ok(); + let query = database::parse_query("").unwrap(); + for file in conn.tb.query(&query, None).unwrap() { + add_to_store(&file, &conn.file_dir); + } +} + +fn main() { + let conn = DatabaseConnection::new().unwrap(); + create_image_directory(&conn); + rocket::ignite() + .mount("/public", StaticFiles::from("./static/")) + .mount("/image", StaticFiles::from(&conn.file_dir)) + .mount("/", routes![index, upload, display_posts, display_post]) + .mount("/api", routes![get_posts, get_post, put_post, update_post]) + .attach(Template::fairing()) + .manage(Mutex::new(conn)) + .launch(); +} |