diff options
| -rw-r--r-- | Cargo.toml | 4 | ||||
| -rw-r--r-- | src/kona-cli.rs | 17 | ||||
| -rw-r--r-- | src/kona-web.rs | 181 | ||||
| -rw-r--r-- | src/lib.rs (renamed from src/database.rs) | 16 |
4 files changed, 123 insertions, 95 deletions
@@ -24,6 +24,10 @@ serde = "1.0" serde_json = "1.0" serde_derive = "1.0" +[lib] +name = "kona" +path = "src/lib.rs" + [[bin]] name = "kona-cli" path = "src/kona-cli.rs" diff --git a/src/kona-cli.rs b/src/kona-cli.rs index 04adfc8..74d7c7f 100644 --- a/src/kona-cli.rs +++ b/src/kona-cli.rs @@ -15,18 +15,11 @@ // You should have received a copy of the GNU Affero General Public // License along with Kona. If not, see <http://www.gnu.org/licenses/>. -#[macro_use] -extern crate anyhow; -extern crate dirs; -#[macro_use] -extern crate rusqlite; - -mod database; - -use database::TagDatabase; use std::env; use std::path::Path; +use kona::TagDatabase; + fn main() { let mut args: Vec<String> = env::args().collect(); if args.len() < 2 || args.iter().any(|arg| arg == "-h" || arg == "--help") { @@ -50,7 +43,7 @@ fn main() { } args.remove(i); - TagDatabase::new(args.remove(i + 1)) + TagDatabase::new(args.remove(i)) } else if let Ok(path) = std::env::var("TB_PATH") { TagDatabase::new(path) } else { @@ -100,9 +93,9 @@ fn main() { } "query" => { let query = if args.len() < 3 { - database::parse_query("").unwrap() + kona::parse_query("").unwrap() } else { - database::parse_query(&args[2]).unwrap() + kona::parse_query(&args[2]).unwrap() }; for file in tb.query(&query, None).unwrap() { println!("{}", file.path); diff --git a/src/kona-web.rs b/src/kona-web.rs index 2d63772..60fb88e 100644 --- a/src/kona-web.rs +++ b/src/kona-web.rs @@ -24,14 +24,16 @@ extern crate image; extern crate rocket; extern crate rocket_contrib; #[macro_use] -extern crate rusqlite; -#[macro_use] extern crate serde_derive; -mod database; +use std::convert::AsRef; +use std::fs; +use std::io::Write; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::sync::Mutex; use anyhow::Result; -use database::{Keyset, TagDatabase}; use image::ImageFormat; use multipart::{MultipartFormData, MultipartFormDataField, MultipartFormDataOptions}; use rand::distributions::Alphanumeric; @@ -43,13 +45,7 @@ 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; +use kona::{Keyset, TagDatabase}; /// Maximum number of results returned by any API endpoint. const RESULTS_PER_QUERY: i64 = 50; @@ -57,7 +53,11 @@ 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. +/// Bundle of everything pertaining to database access. +/// +/// SQLite handles concurrent access reasonably well, but Kona is designed for +/// the single-user use case. If this turns out to be a problem, the +/// `TagDatabase` connection will be nixed. struct DatabaseConnection { tb: TagDatabase, file_dir: String, @@ -75,7 +75,7 @@ impl DatabaseConnection { } } -/// Information about a file, as returned by the 'posts' endpoint. +/// Serialization protocol for a file entry, used by the 'posts' endpoint. #[derive(Debug, Serialize)] struct FileResult { id: i64, @@ -85,24 +85,31 @@ struct FileResult { tags: Vec<String>, } +/// Serialization protocol for a tag entry, used by the 'tags' endpoint. +type TagResult = (i64, String); + /// Return whether or not `filename` ends with one of `extensions`. -fn has_extension<T: AsRef<Path>>(filename: T, extensions: Vec<&str>) -> bool { +fn has_extension<T, S>(filename: T, extensions: &[S]) -> bool +where + T: AsRef<Path>, + S: AsRef<str>, +{ filename .as_ref() .extension() .and_then(|ext| ext.to_str()) - .map(|ext| extensions.contains(&ext)) + .map(|ext| extensions.iter().any(|other| ext == other.as_ref())) .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"]) + 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"]) + has_extension(filename, &vec!["png", "jpg", "jpeg", "gif"]) } /// Return the store file name of `filename`. @@ -133,13 +140,28 @@ fn thumb_filename<T: AsRef<Path>>(id: i64, path: T) -> String { } } +/// Macro for obtaining a reference to the `tb` field of `SiteState`. +/// +/// Ownership boundaries are quite difficult with mutexes, so I've opted to just +/// expand the call for locking the database. +macro_rules! lock_database { + ($conn:expr) => {{ + $conn + .inner() + .lock() + .map_err(|_| anyhow!("Could not lock database."))? + }}; +} + +#[get("/tags")] +fn get_tags(conn: SiteState) -> Result<Json<Vec<TagResult>>> { + let tb = &lock_database!(conn).tb; + Ok(Json(tb.top_tags(None)?)) +} + #[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 tb = &lock_database!(conn).tb; let keyset = if let Some(id) = last { Keyset { @@ -152,10 +174,9 @@ fn get_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Json<V how_many: RESULTS_PER_QUERY, } }; - let results = tb.query(&database::parse_query(query)?, Some(keyset))?; Ok(Json( - results + tb.query(&kona::parse_query(query)?, Some(keyset))? .iter() .map(|file| FileResult { id: file.id, @@ -170,11 +191,7 @@ fn get_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Json<V #[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 tb = &lock_database!(conn).tb; let file = tb.file_by_id(id)?; Ok(Json(FileResult { @@ -192,22 +209,34 @@ struct UpdateTags { } #[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!"))) +fn update_post(conn: SiteState, id: i64, form: Form<UpdateTags>) -> Result<()> { + let tb = &lock_database!(conn).tb; + + let mut adding = Vec::new(); + let mut removing = Vec::new(); + + for atom in form.tags.split(',') { + if atom.starts_with("-") { + removing.push(&atom[1..]); + } else { + adding.push(atom); + } + } + + if !adding.is_empty() { + tb.tag_file(id, &adding[..])?; + } + if !removing.is_empty() { + tb.untag_file(id, &removing[..])?; + } + + Ok(()) } #[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."))?; +fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Result<()> { + let conn = lock_database!(conn); + let options = MultipartFormDataOptions::with_multipart_form_data_fields(vec![ MultipartFormDataField::text("tags"), MultipartFormDataField::raw("image") @@ -226,8 +255,7 @@ fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Result<J .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)?; + fs::File::create(&path)?.write_all(&raw.raw)?; let tags = if let Some(mut vec) = data.texts.remove("tags") { let text = vec.remove(0).text; @@ -238,7 +266,7 @@ fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Result<J let file = conn.tb.add_file(&path, &tags)?; add_to_store(&file, &conn.file_dir); - Ok(Json("Uploaded!".into())) + Ok(()) } #[get("/")] @@ -266,17 +294,11 @@ fn display_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Te next_page: String, } - let base_params = format!("/posts?query={}", query); + // 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 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; + let tb = &lock_database!(conn).tb; tb.top_tags(Some(20))? .iter() .map(|tup| TagResult { @@ -285,18 +307,22 @@ fn display_posts(conn: SiteState, query: String, last: Option<i64>) -> Result<Te }) .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("") + + let images = get_posts(conn, query.clone(), last)?.into_inner(); + + Ok(Template::render( + "index", + Context { + tags: tag_results, + next_page: if images.len() as i64 == RESULTS_PER_QUERY { + let last_image = images.last().unwrap(); + format!("/posts?query={}&last={}", query, last_image.id) + } else { + String::from("") + }, + images, }, - images, - }; - Ok(Template::render("index", context)) + )) } #[get("/posts/<id>")] @@ -312,13 +338,12 @@ fn display_post(conn: SiteState, id: i64) -> Result<Template> { is_unknown: bool, } - let tb = &conn - .inner() - .lock() - .map_err(|_| anyhow!("Could not lock database."))? - .tb; + let tb = &lock_database!(conn).tb; + let file = tb.file_by_id(id)?; + let is_image = is_image(&file.path); + let is_video = is_video(&file.path); let orig_filename = String::from( Path::new(&file.path) .file_name() @@ -327,9 +352,6 @@ fn display_post(conn: SiteState, id: i64) -> Result<Template> { .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 { @@ -345,7 +367,7 @@ fn display_post(conn: SiteState, id: i64) -> Result<Template> { } /// Create a thumbnail for `file` in the store at `file_dir`. -fn add_to_store(file: &database::File, file_dir: &str) { +fn add_to_store(file: &kona::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(); @@ -357,7 +379,7 @@ fn add_to_store(file: &database::File, file_dir: &str) { 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(); + let out = &mut fs::File::create(&thumb_path).unwrap(); im.thumbnail(100, 100) .write_to(out, ImageFormat::Png) .unwrap(); @@ -386,7 +408,7 @@ fn add_to_store(file: &database::File, file_dir: &str) { /// 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(); + let query = kona::parse_query("").unwrap(); for file in conn.tb.query(&query, None).unwrap() { add_to_store(&file, &conn.file_dir); } @@ -399,7 +421,10 @@ fn main() { .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]) + .mount( + "/api", + routes![get_posts, get_tags, get_post, put_post, update_post], + ) .attach(Template::fairing()) .manage(Mutex::new(conn)) .launch(); diff --git a/src/database.rs b/src/lib.rs index 0702ed9..6665eee 100644 --- a/src/database.rs +++ b/src/lib.rs @@ -15,12 +15,17 @@ // You should have received a copy of the GNU Affero General Public // License along with Kona. If not, see <http://www.gnu.org/licenses/>. -use anyhow::Result; -use rusqlite::Connection; +#[macro_use] +extern crate anyhow; +#[macro_use] +extern crate rusqlite; use std::convert::AsRef; use std::path::Path; +use anyhow::Result; +use rusqlite::Connection; + /// Type of constraint on a column. #[derive(Debug, PartialEq)] pub enum Atom { @@ -238,9 +243,10 @@ impl TagDatabase { // Silently skip any tags which do not exist in the database. if let Ok(tags) = self.tag_ids(tag.as_ref()) { for tag in tags.iter() { - self.0 - .prepare("DELETE FROM mapping WHERE file = ? AND tag = ?")? - .insert(params![id, tag])?; + self.0.execute( + "DELETE FROM mapping WHERE file = ? AND tag = ?", + params![id, tag], + )?; } } } |