From 8e89d43bdf35b2a1f69f3f7e3d87c57fb1e3f58a Mon Sep 17 00:00:00 2001 From: "Jakob L. Kreuze" Date: Sat, 6 Jun 2020 18:59:03 -0400 Subject: Implement file uploading. --- Cargo.lock | 349 +++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + README.md | 6 +- src/birka-web.rs | 303 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 242 ----------------------------------- static/css/style.css | 10 ++ templates/upload.hbs | 22 ++++ 7 files changed, 680 insertions(+), 254 deletions(-) create mode 100644 src/birka-web.rs delete mode 100644 src/main.rs create mode 100644 templates/upload.hbs diff --git a/Cargo.lock b/Cargo.lock index 1492cbe..27a846a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + [[package]] name = "autocfg" version = "1.0.0" @@ -90,7 +96,9 @@ dependencies = [ "blake2", "dirs", "image", + "rand 0.7.3", "rocket", + "rocket-multipart-form-data", "rocket_contrib", "rusqlite", "serde", @@ -148,6 +156,16 @@ dependencies = [ "byte-tools", ] +[[package]] +name = "buf_redux" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "byte-tools" version = "0.3.1" @@ -178,6 +196,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags", +] + [[package]] name = "color_quant" version = "1.0.1" @@ -228,7 +255,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" dependencies = [ - "autocfg", + "autocfg 1.0.0", "cfg-if", "crossbeam-utils", "lazy_static", @@ -253,7 +280,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" dependencies = [ - "autocfg", + "autocfg 1.0.0", "cfg-if", "lazy_static", ] @@ -341,6 +368,17 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "educe" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c760a73a13abb1ddf417c06bc2b8d14b0607dd19097316e0ca9412a971088" +dependencies = [ + "proc-macro2 1.0.17", + "quote 1.0.6", + "syn 1.0.25", +] + [[package]] name = "either" version = "1.5.3" @@ -396,6 +434,12 @@ dependencies = [ "libc", ] +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -490,7 +534,7 @@ dependencies = [ "httparse", "language-tags", "log 0.3.9", - "mime", + "mime 0.2.6", "num_cpus", "time", "traitobject", @@ -534,7 +578,7 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "076f042c5b7b98f31d205f1249267e12a6518c1481e9dae9764af19b707d2292" dependencies = [ - "autocfg", + "autocfg 1.0.0", ] [[package]] @@ -704,7 +748,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4fc2c02a7e374099d4ee95a193111f72d2110197fe200272371758f6c3643d8" dependencies = [ - "autocfg", + "autocfg 1.0.0", ] [[package]] @@ -716,6 +760,24 @@ dependencies = [ "log 0.3.9", ] +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "mime_guess" +version = "1.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216929a5ee4dd316b1702eedf5e74548c123d370f47841ceaac38ca154690ca3" +dependencies = [ + "mime 0.2.6", + "phf", + "phf_codegen", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.3.6" @@ -768,6 +830,24 @@ dependencies = [ "ws2_32-sys", ] +[[package]] +name = "multipart" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136eed74cadb9edd2651ffba732b19a450316b680e4f48d6c79e905799e19d01" +dependencies = [ + "buf_redux", + "httparse", + "log 0.4.8", + "mime 0.2.6", + "mime_guess", + "quick-error", + "rand 0.6.5", + "safemem", + "tempfile", + "twoway", +] + [[package]] name = "net2" version = "0.2.34" @@ -803,7 +883,7 @@ version = "0.1.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f6ea62e9d81a77cd3ee9a2a5b9b609447857f3d358704331e4ef39eb247fcba" dependencies = [ - "autocfg", + "autocfg 1.0.0", "num-traits", ] @@ -813,7 +893,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfb0800a0291891dd9f4fe7bd9c19384f98f7fbe0cd0f39a2c6b88b9868bbc00" dependencies = [ - "autocfg", + "autocfg 1.0.0", "num-integer", "num-traits", ] @@ -824,7 +904,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" dependencies = [ - "autocfg", + "autocfg 1.0.0", "num-integer", "num-traits", ] @@ -835,7 +915,7 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096" dependencies = [ - "autocfg", + "autocfg 1.0.0", ] [[package]] @@ -925,6 +1005,45 @@ dependencies = [ "sha-1", ] +[[package]] +name = "phf" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03e85129e324ad4166b06b2c7491ae27fe3ec353af72e72cd1654c7225d517e" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662" +dependencies = [ + "phf_shared", + "rand 0.6.5", +] + +[[package]] +name = "phf_shared" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0" +dependencies = [ + "siphasher", + "unicase", +] + [[package]] name = "pkg-config" version = "0.3.17" @@ -943,6 +1062,12 @@ dependencies = [ "inflate", ] +[[package]] +name = "ppv-lite86" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "237a5ed80e274dbc66f86bd59c1e25edc039660be53194b5fe0a482e0f2612ea" + [[package]] name = "proc-macro2" version = "0.4.30" @@ -985,6 +1110,153 @@ dependencies = [ "proc-macro2 1.0.17", ] +[[package]] +name = "rand" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca" +dependencies = [ + "autocfg 0.1.7", + "libc", + "rand_chacha 0.1.1", + "rand_core 0.4.2", + "rand_hc 0.1.0", + "rand_isaac", + "rand_jitter", + "rand_os", + "rand_pcg", + "rand_xorshift", + "winapi 0.3.8", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", +] + +[[package]] +name = "rand_chacha" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_isaac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_jitter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b" +dependencies = [ + "libc", + "rand_core 0.4.2", + "winapi 0.3.8", +] + +[[package]] +name = "rand_os" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.4.2", + "rdrand", + "winapi 0.3.8", +] + +[[package]] +name = "rand_pcg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.4.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "rayon" version = "1.3.0" @@ -1009,6 +1281,15 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redox_syscall" version = "0.1.56" @@ -1044,6 +1325,15 @@ version = "0.6.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fe5bd57d1d7414c6b5ed48563a2c855d995ff777729dcd91c369ec7fea395ae" +[[package]] +name = "remove_dir_all" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" +dependencies = [ + "winapi 0.3.8", +] + [[package]] name = "ring" version = "0.13.5" @@ -1077,6 +1367,18 @@ dependencies = [ "yansi 0.5.0", ] +[[package]] +name = "rocket-multipart-form-data" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38ffbd5126f8df80346e7945028d5dd5ea81f6cf0e4b6310942ce6d7e5db691" +dependencies = [ + "educe", + "mime 0.3.16", + "multipart", + "rocket", +] + [[package]] name = "rocket_codegen" version = "0.4.4" @@ -1224,6 +1526,12 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "siphasher" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" + [[package]] name = "slab" version = "0.4.2" @@ -1270,6 +1578,20 @@ dependencies = [ "unicode-xid 0.2.0", ] +[[package]] +name = "tempfile" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +dependencies = [ + "cfg-if", + "libc", + "rand 0.7.3", + "redox_syscall", + "remove_dir_all", + "winapi 0.3.8", +] + [[package]] name = "thread_local" version = "1.0.1" @@ -1315,6 +1637,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" +[[package]] +name = "twoway" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" +dependencies = [ + "memchr", +] + [[package]] name = "typeable" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 6087545..a4a707f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ rusqlite = "0.21.0" # Until RFC #2887 is merged, image and rocket are deps of the CLI tool as well. image = "0.23" +multipart = { package = "rocket-multipart-form-data", version = "0.9" } +rand = "0.7" rocket = "0.4" rocket_contrib = { version = "0.4", default-features = false, features = ["handlebars_templates", "json", "serve"] } serde = "1.0" diff --git a/README.md b/README.md index c18c66f..2cf8a74 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,9 @@ GET /api/posts/[id] ImageResult ``` -To upload an image to the database, the blob of data may be POSTED to the -following endpoint, where `filename` is the desired filename of the image (a -string): +To upload an image to the database, post a multipart/form-data encoded message +to the following endpoint with the following fields: `tags`, a comma-separated +list of tags for the files, and `image`, the file to be uploaded. ``` POST /api/posts/[filename] 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 +// +// 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 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>; + +/// Everything necessary to serve images from the tag database. +struct DatabaseConnection { + tb: TagDatabase, + image_dir: String, +} + +impl DatabaseConnection { + fn new() -> Result { + // 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, +} + +/// 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?&")] +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::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/")] +fn get_post(conn: SiteState, id: i64) -> Json { + 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 = "")] +fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Json { + 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?&")] +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) +} + +#[get("/posts/")] +fn display_post(conn: SiteState, id: i64) -> Template { + #[derive(Serialize)] + struct Context { + tags: Vec, + 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(); +} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 2a5b440..0000000 --- a/src/main.rs +++ /dev/null @@ -1,242 +0,0 @@ -// 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, - orig_filename: String, - tags: Vec, -} - -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 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?&")] -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_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/")] -fn get_post(conn: SiteState, id: i64) -> Json { - 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 = "")] -fn put_post(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) -} - -#[get("/posts/")] -fn display_post(conn: SiteState, id: i64) -> Template { - Template::render("image", get_post(conn, id).into_inner()) -} - -/// 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 = image_store_filename(image.id, &image.filename); - let dst = Path::new(&conn.image_dir).join(dst); - symlink(&src, &dst).ok(); - - let thumb_path = thumb_filename(image.id, &image.filename); - let thumb_path = Path::new(&conn.image_dir).join(&thumb_path); - if !thumb_path.exists() { - let im = image::open(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, display_post]) - .mount("/api", routes![get_posts, get_post, put_post]) - .attach(Template::fairing()) - .manage(Mutex::new(conn)) - .launch(); -} diff --git a/static/css/style.css b/static/css/style.css index b32823e..017622a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -18,3 +18,13 @@ aside { main { grid-column: 2 / 6; grid-row: 2 +} + +#upload { + width: 20%; +} + +#upload input { + min-width: 100%; + margin-bottom: 16px; +} diff --git a/templates/upload.hbs b/templates/upload.hbs new file mode 100644 index 0000000..ca57d4f --- /dev/null +++ b/templates/upload.hbs @@ -0,0 +1,22 @@ + + + + бирка-тян - Upload File + + + + +
+

бирка-тян

+
+
+

Upload a file

+
+ + + + +
+
+ + -- cgit v1.3