diff --git a/src/adm/precache.rs b/src/adm/precache.rs index a4622b3..9564f80 100644 --- a/src/adm/precache.rs +++ b/src/adm/precache.rs @@ -1,8 +1,7 @@ use super::result::Error; -use crate::models::Photo; +use crate::models::{Photo, SizeTag}; use crate::photosdir::PhotosDir; use crate::schema::photos::dsl::{date, is_public}; -use crate::server::SizeTag; use crate::{CacheOpt, DbOpt, DirOpt}; use diesel::prelude::*; use log::{debug, info}; diff --git a/src/models.rs b/src/models.rs index 830ed10..85304af 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,3 @@ -use crate::server::SizeTag; use chrono::naive::NaiveDateTime; use diesel; use diesel::pg::PgConnection; @@ -345,3 +344,20 @@ impl From<(i32, i32)> for Coord { } } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SizeTag { + Small, + Medium, + Large, +} + +impl SizeTag { + pub fn px(self) -> u32 { + match self { + SizeTag::Small => 240, + SizeTag::Medium => 960, + SizeTag::Large => 1900, + } + } +} diff --git a/src/server/admin.rs b/src/server/admin.rs index 1b59380..56c5e4f 100644 --- a/src/server/admin.rs +++ b/src/server/admin.rs @@ -1,7 +1,7 @@ //! Admin-only views, generally called by javascript. -use super::{not_found, permission_denied, redirect_to_img, Context, SizeTag}; +use super::{not_found, permission_denied, redirect_to_img, Context}; use crate::fetch_places::update_image_places; -use crate::models::{Coord, Photo}; +use crate::models::{Coord, Photo, SizeTag}; use diesel::{self, prelude::*}; use log::{info, warn}; use serde::Deserialize; diff --git a/src/server/image.rs b/src/server/image.rs new file mode 100644 index 0000000..04b827a --- /dev/null +++ b/src/server/image.rs @@ -0,0 +1,111 @@ +use super::render_ructe::RenderRucte; +use super::{error_response, not_found, Context}; +use crate::models::{Photo, SizeTag}; +use diesel::prelude::*; +use std::str::FromStr; +use warp::http::{header, Response, StatusCode}; + +pub fn show_image(img: ImgName, context: Context) -> Response> { + use crate::schema::photos::dsl::photos; + if let Ok(tphoto) = photos.find(img.id).first::(context.db()) { + if context.is_authorized() || tphoto.is_public() { + if img.size == SizeTag::Large { + if context.is_authorized() { + use std::fs::File; + use std::io::Read; + // TODO: This should be done in a more async-friendly way. + let path = context.photos().get_raw_path(&tphoto); + let mut buf = Vec::new(); + if File::open(path) + .map(|mut f| f.read_to_end(&mut buf)) + .is_ok() + { + return Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + mime::IMAGE_JPEG.as_ref(), + ) + .far_expires() + .body(buf) + .unwrap(); + } else { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + ); + } + } + } else { + let data = get_image_data(&context, &tphoto, img.size) + .expect("Get image data"); + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime::IMAGE_JPEG.as_ref()) + .far_expires() + .body(data) + .unwrap(); + } + } + } + not_found(&context) +} + +/// A client-side / url file name for a file. +/// Someting like 4711-s.jpg +#[derive(Debug, Eq, PartialEq)] +pub struct ImgName { + id: i32, + size: SizeTag, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct BadImgName {} + +impl FromStr for ImgName { + type Err = BadImgName; + fn from_str(s: &str) -> Result { + if let Some(pos) = s.find('-') { + let (num, rest) = s.split_at(pos); + let id = num.parse().map_err(|_| BadImgName {})?; + let size = match rest { + "-s.jpg" => SizeTag::Small, + "-m.jpg" => SizeTag::Medium, + "-l.jpg" => SizeTag::Large, + _ => return Err(BadImgName {}), + }; + return Ok(ImgName { id, size }); + } + Err(BadImgName {}) + } +} + +#[test] +fn parse_good_imgname() { + assert_eq!( + "4711-s.jpg".parse(), + Ok(ImgName { + id: 4711, + size: SizeTag::Small, + }) + ) +} + +#[test] +fn parse_bad_imgname_1() { + assert_eq!("4711-q.jpg".parse::(), Err(BadImgName {})) +} +#[test] +fn parse_bad_imgname_2() { + assert_eq!("blurgel".parse::(), Err(BadImgName {})) +} + +fn get_image_data( + context: &Context, + photo: &Photo, + size: SizeTag, +) -> Result, image::ImageError> { + context.cached_or(&photo.cache_key(size), || { + let size = size.px(); + context.photos().scale_image(photo, size, size) + }) +} diff --git a/src/server/login.rs b/src/server/login.rs new file mode 100644 index 0000000..b9c8264 --- /dev/null +++ b/src/server/login.rs @@ -0,0 +1,103 @@ +use super::render_ructe::RenderRucte; +use super::Context; +use crate::templates; +use diesel::prelude::*; +use log::info; +use serde::Deserialize; +use warp::http::{header, Response}; + +pub fn get_login(context: Context, param: NextQ) -> Response> { + info!("Got request for login form. Param: {:?}", param); + let next = sanitize_next(param.next.as_ref().map(AsRef::as_ref)); + Response::builder().html(|o| templates::login(o, &context, next, None)) +} + +#[derive(Debug, Default, Deserialize)] +pub struct NextQ { + next: Option, +} + +pub fn post_login(context: Context, form: LoginForm) -> Response> { + let next = sanitize_next(form.next.as_ref().map(AsRef::as_ref)); + use crate::schema::users::dsl::*; + if let Ok(hash) = users + .filter(username.eq(&form.user)) + .select(password) + .first::(context.db()) + { + if djangohashers::check_password_tolerant(&form.password, &hash) { + info!("User {} logged in", form.user); + let token = context.make_token(&form.user).unwrap(); + return Response::builder() + .header( + header::SET_COOKIE, + format!("EXAUTH={}; SameSite=Strict; HttpOpnly", token), + ) + .redirect(next.unwrap_or("/")); + } + info!( + "Login failed: Password verification failed for {:?}", + form.user, + ); + } else { + info!("Login failed: No hash found for {:?}", form.user); + } + let message = Some("Login failed, please try again"); + Response::builder().html(|o| templates::login(o, &context, next, message)) +} + +/// The data submitted by the login form. +/// This does not derive Debug or Serialize, as the password is plain text. +#[derive(Deserialize)] +pub struct LoginForm { + user: String, + password: String, + next: Option, +} + +fn sanitize_next(next: Option<&str>) -> Option<&str> { + if let Some(next) = next { + use regex::Regex; + let re = Regex::new(r"^/([a-z0-9._-]+/?)*$").unwrap(); + if re.is_match(next) { + return Some(next); + } + } + None +} + +#[test] +fn test_sanitize_bad_1() { + assert_eq!(None, sanitize_next(Some("https://evil.org/"))) +} + +#[test] +fn test_sanitize_bad_2() { + assert_eq!(None, sanitize_next(Some("//evil.org/"))) +} +#[test] +fn test_sanitize_bad_3() { + assert_eq!(None, sanitize_next(Some("/evil\"hack"))) +} +#[test] +fn test_sanitize_bad_4() { + assert_eq!(None, sanitize_next(Some("/evil'hack"))) +} + +#[test] +fn test_sanitize_good_1() { + assert_eq!(Some("/foo/"), sanitize_next(Some("/foo/"))) +} +#[test] +fn test_sanitize_good_2() { + assert_eq!(Some("/2017/7/15"), sanitize_next(Some("/2017/7/15"))) +} + +pub fn logout(_context: Context) -> Response> { + Response::builder() + .header( + header::SET_COOKIE, + "EXAUTH=; Max-Age=0; SameSite=Strict; HttpOpnly", + ) + .redirect("/") +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 1bcdb04..e673ed0 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,32 +1,34 @@ -#[macro_use] mod admin; mod context; +mod image; +mod login; +mod photolink; mod render_ructe; mod splitlist; +mod views_by_category; mod views_by_date; use self::context::create_session_filter; pub use self::context::Context; +pub use self::photolink::PhotoLink; use self::render_ructe::RenderRucte; use self::splitlist::*; +use self::views_by_category::*; use self::views_by_date::*; use super::{CacheOpt, DbOpt, DirOpt}; use crate::adm::result::Error; -use crate::models::{Person, Photo, Place, Tag}; +use crate::models::Photo; use crate::pidfiles::handle_pid_file; use crate::templates::{self, Html}; use chrono::Datelike; use diesel::prelude::*; -use djangohashers; -use image; use log::info; -use mime; use serde::Deserialize; use std::net::SocketAddr; use structopt::StructOpt; use warp::filters::path::Tail; use warp::http::{header, Response, StatusCode}; -use warp::{self, reply, Filter, Rejection, Reply}; +use warp::{self, Filter, Rejection, Reply}; #[derive(StructOpt)] #[structopt(rename_all = "kebab-case")] @@ -57,127 +59,6 @@ pub struct Args { jwt_key: String, } -pub struct PhotoLink { - pub title: Option, - pub href: String, - pub id: i32, - pub size: (u32, u32), - pub lable: Option, -} - -impl PhotoLink { - fn for_group(g: &[Photo], base_url: &str, with_date: bool) -> PhotoLink { - if g.len() == 1 { - if with_date { - PhotoLink::date_title(&g[0]) - } else { - PhotoLink::no_title(&g[0]) - } - } else { - fn imgscore(p: &Photo) -> i16 { - // Only score below 19 is worse than ungraded. - p.grade.unwrap_or(19) * if p.is_public { 5 } else { 4 } - } - let photo = g.iter().max_by_key(|p| imgscore(p)).unwrap(); - let (title, lable) = { - let from = g.last().and_then(|p| p.date); - let to = g.first().and_then(|p| p.date); - if let (Some(from), Some(to)) = (from, to) { - if from.date() == to.date() { - ( - Some(from.format("%F").to_string()), - format!( - "{} - {} ({})", - from.format("%R"), - to.format("%R"), - g.len(), - ), - ) - } else if from.year() == to.year() { - if from.month() == to.month() { - ( - Some(from.format("%Y-%m").to_string()), - format!( - "{} - {} ({})", - from.format("%F"), - to.format("%d"), - g.len(), - ), - ) - } else { - ( - Some(from.format("%Y").to_string()), - format!( - "{} - {} ({})", - from.format("%F"), - to.format("%m-%d"), - g.len(), - ), - ) - } - } else { - ( - None, - format!( - "{} - {} ({})", - from.format("%F"), - to.format("%F"), - g.len(), - ), - ) - } - } else { - ( - None, - format!( - "{} - {} ({})", - from.map(|d| format!("{}", d.format("%F %R"))) - .unwrap_or_else(|| "-".to_string()), - to.map(|d| format!("{}", d.format("%F %R"))) - .unwrap_or_else(|| "-".to_string()), - g.len(), - ), - ) - } - }; - let title = if with_date { title } else { None }; - PhotoLink { - title, - href: format!( - "{}?from={}&to={}", - base_url, - g.last().map(|p| p.id).unwrap_or(0), - g.first().map(|p| p.id).unwrap_or(0), - ), - id: photo.id, - size: photo.get_size(SizeTag::Small), - lable: Some(lable), - } - } - } - fn date_title(p: &Photo) -> PhotoLink { - PhotoLink { - title: p.date.map(|d| d.format("%F").to_string()), - href: format!("/img/{}", p.id), - id: p.id, - size: p.get_size(SizeTag::Small), - lable: p.date.map(|d| d.format("%T").to_string()), - } - } - fn no_title(p: &Photo) -> PhotoLink { - PhotoLink { - title: None, // p.date.map(|d| d.format("%F").to_string()), - href: format!("/img/{}", p.id), - id: p.id, - size: p.get_size(SizeTag::Small), - lable: p.date.map(|d| d.format("%T").to_string()), - } - } - pub fn is_portrait(&self) -> bool { - self.size.1 > self.size.0 - } -} - pub fn run(args: &Args) -> Result<(), Error> { if let Some(pidfile) = &args.pidfile { handle_pid_file(&pidfile, args.replace).unwrap() @@ -199,12 +80,12 @@ pub fn run(args: &Args) -> Result<(), Error> { #[rustfmt::skip] let routes = warp::any() .and(static_routes) - .or(get().and(path("login")).and(end()).and(s()).and(query()).map(login)) - .or(post().and(path("login")).and(end()).and(s()).and(body::form()).map(do_login)) - .or(path("logout").and(end()).and(s()).map(logout)) + .or(get().and(path("login")).and(end()).and(s()).and(query()).map(login::get_login)) + .or(post().and(path("login")).and(end()).and(s()).and(body::form()).map(login::post_login)) + .or(path("logout").and(end()).and(s()).map(login::logout)) .or(get().and(end()).and(s()).map(all_years)) .or(get().and(path("img")).and(param()).and(end()).and(s()).map(photo_details)) - .or(get().and(path("img")).and(param()).and(end()).and(s()).map(show_image)) + .or(get().and(path("img")).and(param()).and(end()).and(s()).map(image::show_image)) .or(get().and(path("0")).and(end()).and(s()).map(all_null_date)) .or(get().and(param()).and(end()).and(s()).map(months_in_year)) .or(get().and(param()).and(param()).and(end()).and(s()).map(days_in_month)) @@ -277,287 +158,6 @@ fn error_response(err: StatusCode) -> Response> { .html(|o| templates::error(o, err, "Sorry about this.")) } -fn login(context: Context, param: NextQ) -> Response> { - info!("Got request for login form. Param: {:?}", param); - let next = sanitize_next(param.next.as_ref().map(AsRef::as_ref)); - Response::builder().html(|o| templates::login(o, &context, next, None)) -} - -#[derive(Debug, Default, Deserialize)] -struct NextQ { - next: Option, -} - -fn do_login(context: Context, form: LoginForm) -> Response> { - let next = sanitize_next(form.next.as_ref().map(AsRef::as_ref)); - use crate::schema::users::dsl::*; - if let Ok(hash) = users - .filter(username.eq(&form.user)) - .select(password) - .first::(context.db()) - { - if djangohashers::check_password_tolerant(&form.password, &hash) { - info!("User {} logged in", form.user); - let token = context.make_token(&form.user).unwrap(); - return Response::builder() - .header( - header::SET_COOKIE, - format!("EXAUTH={}; SameSite=Strict; HttpOpnly", token), - ) - .redirect(next.unwrap_or("/")); - } - info!( - "Login failed: Password verification failed for {:?}", - form.user, - ); - } else { - info!("Login failed: No hash found for {:?}", form.user); - } - let message = Some("Login failed, please try again"); - Response::builder().html(|o| templates::login(o, &context, next, message)) -} - -/// The data submitted by the login form. -/// This does not derive Debug or Serialize, as the password is plain text. -#[derive(Deserialize)] -struct LoginForm { - user: String, - password: String, - next: Option, -} - -fn sanitize_next(next: Option<&str>) -> Option<&str> { - if let Some(next) = next { - use regex::Regex; - let re = Regex::new(r"^/([a-z0-9._-]+/?)*$").unwrap(); - if re.is_match(next) { - return Some(next); - } - } - None -} - -#[test] -fn test_sanitize_bad_1() { - assert_eq!(None, sanitize_next(Some("https://evil.org/"))) -} - -#[test] -fn test_sanitize_bad_2() { - assert_eq!(None, sanitize_next(Some("//evil.org/"))) -} -#[test] -fn test_sanitize_bad_3() { - assert_eq!(None, sanitize_next(Some("/evil\"hack"))) -} -#[test] -fn test_sanitize_bad_4() { - assert_eq!(None, sanitize_next(Some("/evil'hack"))) -} - -#[test] -fn test_sanitize_good_1() { - assert_eq!(Some("/foo/"), sanitize_next(Some("/foo/"))) -} -#[test] -fn test_sanitize_good_2() { - assert_eq!(Some("/2017/7/15"), sanitize_next(Some("/2017/7/15"))) -} - -fn logout(_context: Context) -> Response> { - Response::builder() - .header( - header::SET_COOKIE, - "EXAUTH=; Max-Age=0; SameSite=Strict; HttpOpnly", - ) - .redirect("/") -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SizeTag { - Small, - Medium, - Large, -} -impl SizeTag { - pub fn px(self) -> u32 { - match self { - SizeTag::Small => 240, - SizeTag::Medium => 960, - SizeTag::Large => 1900, - } - } -} - -fn show_image(img: ImgName, context: Context) -> Response> { - use crate::schema::photos::dsl::photos; - if let Ok(tphoto) = photos.find(img.id).first::(context.db()) { - if context.is_authorized() || tphoto.is_public() { - if img.size == SizeTag::Large { - if context.is_authorized() { - use std::fs::File; - use std::io::Read; - // TODO: This should be done in a more async-friendly way. - let path = context.photos().get_raw_path(&tphoto); - let mut buf = Vec::new(); - if File::open(path) - .map(|mut f| f.read_to_end(&mut buf)) - .is_ok() - { - return Response::builder() - .status(StatusCode::OK) - .header( - header::CONTENT_TYPE, - mime::IMAGE_JPEG.as_ref(), - ) - .far_expires() - .body(buf) - .unwrap(); - } else { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - ); - } - } - } else { - let data = get_image_data(&context, &tphoto, img.size) - .expect("Get image data"); - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime::IMAGE_JPEG.as_ref()) - .far_expires() - .body(data) - .unwrap(); - } - } - } - not_found(&context) -} - -/// A client-side / url file name for a file. -/// Someting like 4711-s.jpg -#[derive(Debug, Eq, PartialEq)] -struct ImgName { - id: i32, - size: SizeTag, -} -use std::str::FromStr; -#[derive(Debug, Eq, PartialEq)] -struct BadImgName {} -impl FromStr for ImgName { - type Err = BadImgName; - fn from_str(s: &str) -> Result { - if let Some(pos) = s.find('-') { - let (num, rest) = s.split_at(pos); - let id = num.parse().map_err(|_| BadImgName {})?; - let size = match rest { - "-s.jpg" => SizeTag::Small, - "-m.jpg" => SizeTag::Medium, - "-l.jpg" => SizeTag::Large, - _ => return Err(BadImgName {}), - }; - return Ok(ImgName { id, size }); - } - Err(BadImgName {}) - } -} - -#[test] -fn parse_good_imgname() { - assert_eq!( - "4711-s.jpg".parse(), - Ok(ImgName { - id: 4711, - size: SizeTag::Small, - }) - ) -} - -#[test] -fn parse_bad_imgname_1() { - assert_eq!("4711-q.jpg".parse::(), Err(BadImgName {})) -} -#[test] -fn parse_bad_imgname_2() { - assert_eq!("blurgel".parse::(), Err(BadImgName {})) -} - -fn get_image_data( - context: &Context, - photo: &Photo, - size: SizeTag, -) -> Result, image::ImageError> { - context.cached_or(&photo.cache_key(size), || { - let size = size.px(); - context.photos().scale_image(photo, size, size) - }) -} - -fn tag_all(context: Context) -> Response> { - use crate::schema::tags::dsl::{id, tag_name, tags}; - let query = tags.into_boxed(); - let query = if context.is_authorized() { - query - } else { - use crate::schema::photo_tags::dsl as tp; - use crate::schema::photos::dsl as p; - query.filter(id.eq_any(tp::photo_tags.select(tp::tag_id).filter( - tp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), - ))) - }; - Response::builder().html(|o| { - templates::tags( - o, - &context, - &query.order(tag_name).load(context.db()).expect("List tags"), - ) - }) -} - -fn tag_one( - context: Context, - tslug: String, - range: ImgRange, -) -> Response> { - use crate::schema::tags::dsl::{slug, tags}; - if let Ok(tag) = tags.filter(slug.eq(tslug)).first::(context.db()) { - use crate::schema::photo_tags::dsl::{photo_id, photo_tags, tag_id}; - use crate::schema::photos::dsl::id; - let photos = Photo::query(context.is_authorized()).filter( - id.eq_any(photo_tags.select(photo_id).filter(tag_id.eq(tag.id))), - ); - let (links, coords) = links_by_time(&context, photos, range, true); - Response::builder() - .html(|o| templates::tag(o, &context, &links, &coords, &tag)) - } else { - not_found(&context) - } -} - -fn place_all(context: Context) -> Response> { - use crate::schema::places::dsl::{id, place_name, places}; - let query = places.into_boxed(); - let query = if context.is_authorized() { - query - } else { - use crate::schema::photo_places::dsl as pp; - use crate::schema::photos::dsl as p; - query.filter(id.eq_any(pp::photo_places.select(pp::place_id).filter( - pp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), - ))) - }; - Response::builder().html(|o| { - templates::places( - o, - &context, - &query - .order(place_name) - .load(context.db()) - .expect("List places"), - ) - }) -} - /// Handler for static files. /// Create a response from the file data with a correct content type /// and a far expires header (or a 404 if the file does not exist). @@ -575,81 +175,6 @@ fn static_file(name: Tail) -> Result { } } -fn place_one( - context: Context, - tslug: String, - range: ImgRange, -) -> Response> { - use crate::schema::places::dsl::{places, slug}; - if let Ok(place) = - places.filter(slug.eq(tslug)).first::(context.db()) - { - use crate::schema::photo_places::dsl::{ - photo_id, photo_places, place_id, - }; - use crate::schema::photos::dsl::id; - let photos = Photo::query(context.is_authorized()).filter(id.eq_any( - photo_places.select(photo_id).filter(place_id.eq(place.id)), - )); - let (links, coord) = links_by_time(&context, photos, range, true); - Response::builder() - .html(|o| templates::place(o, &context, &links, &coord, &place)) - } else { - not_found(&context) - } -} - -fn person_all(context: Context) -> Response> { - use crate::schema::people::dsl::{id, people, person_name}; - let query = people.into_boxed(); - let query = if context.is_authorized() { - query - } else { - use crate::schema::photo_people::dsl as pp; - use crate::schema::photos::dsl as p; - query.filter(id.eq_any(pp::photo_people.select(pp::person_id).filter( - pp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), - ))) - }; - Response::builder().html(|o| { - templates::people( - o, - &context, - &query - .order(person_name) - .load(context.db()) - .expect("list people"), - ) - }) -} - -fn person_one( - context: Context, - tslug: String, - range: ImgRange, -) -> Response> { - use crate::schema::people::dsl::{people, slug}; - let c = context.db(); - if let Ok(person) = people.filter(slug.eq(tslug)).first::(c) { - use crate::schema::photo_people::dsl::{ - person_id, photo_id, photo_people, - }; - use crate::schema::photos::dsl::id; - let photos = Photo::query(context.is_authorized()).filter( - id.eq_any( - photo_people - .select(photo_id) - .filter(person_id.eq(person.id)), - ), - ); - let (links, coords) = links_by_time(&context, photos, range, true); - Response::builder() - .html(|o| templates::person(o, &context, &links, &coords, &person)) - } else { - not_found(&context) - } -} - fn random_image(context: Context) -> Response> { use crate::schema::photos::dsl::id; use diesel::expression::dsl::sql; @@ -746,31 +271,6 @@ impl Link { } } -fn auto_complete_tag(context: Context, query: AcQ) -> impl Reply { - use crate::schema::tags::dsl::{tag_name, tags}; - let q = tags - .select(tag_name) - .filter(tag_name.ilike(query.q + "%")) - .order(tag_name) - .limit(10); - reply::json(&q.load::(context.db()).unwrap()) -} - -fn auto_complete_person(context: Context, query: AcQ) -> impl Reply { - use crate::schema::people::dsl::{people, person_name}; - let q = people - .select(person_name) - .filter(person_name.ilike(query.q + "%")) - .order(person_name) - .limit(10); - reply::json(&q.load::(context.db()).unwrap()) -} - -#[derive(Deserialize)] -struct AcQ { - q: String, -} - #[derive(Debug, Default, Deserialize)] pub struct ImgRange { pub from: Option, diff --git a/src/server/photolink.rs b/src/server/photolink.rs new file mode 100644 index 0000000..66b1ef4 --- /dev/null +++ b/src/server/photolink.rs @@ -0,0 +1,127 @@ +use crate::models::{Photo, SizeTag}; +use chrono::Datelike; + +pub struct PhotoLink { + pub title: Option, + pub href: String, + pub id: i32, + pub size: (u32, u32), + pub lable: Option, +} + +impl PhotoLink { + pub fn for_group( + g: &[Photo], + base_url: &str, + with_date: bool, + ) -> PhotoLink { + if g.len() == 1 { + if with_date { + PhotoLink::date_title(&g[0]) + } else { + PhotoLink::no_title(&g[0]) + } + } else { + fn imgscore(p: &Photo) -> i16 { + // Only score below 19 is worse than ungraded. + p.grade.unwrap_or(19) * if p.is_public { 5 } else { 4 } + } + let photo = g.iter().max_by_key(|p| imgscore(p)).unwrap(); + let (title, lable) = { + let from = g.last().and_then(|p| p.date); + let to = g.first().and_then(|p| p.date); + if let (Some(from), Some(to)) = (from, to) { + if from.date() == to.date() { + ( + Some(from.format("%F").to_string()), + format!( + "{} - {} ({})", + from.format("%R"), + to.format("%R"), + g.len(), + ), + ) + } else if from.year() == to.year() { + if from.month() == to.month() { + ( + Some(from.format("%Y-%m").to_string()), + format!( + "{} - {} ({})", + from.format("%F"), + to.format("%d"), + g.len(), + ), + ) + } else { + ( + Some(from.format("%Y").to_string()), + format!( + "{} - {} ({})", + from.format("%F"), + to.format("%m-%d"), + g.len(), + ), + ) + } + } else { + ( + None, + format!( + "{} - {} ({})", + from.format("%F"), + to.format("%F"), + g.len(), + ), + ) + } + } else { + ( + None, + format!( + "{} - {} ({})", + from.map(|d| format!("{}", d.format("%F %R"))) + .unwrap_or_else(|| "-".to_string()), + to.map(|d| format!("{}", d.format("%F %R"))) + .unwrap_or_else(|| "-".to_string()), + g.len(), + ), + ) + } + }; + let title = if with_date { title } else { None }; + PhotoLink { + title, + href: format!( + "{}?from={}&to={}", + base_url, + g.last().map(|p| p.id).unwrap_or(0), + g.first().map(|p| p.id).unwrap_or(0), + ), + id: photo.id, + size: photo.get_size(SizeTag::Small), + lable: Some(lable), + } + } + } + pub fn date_title(p: &Photo) -> PhotoLink { + PhotoLink { + title: p.date.map(|d| d.format("%F").to_string()), + href: format!("/img/{}", p.id), + id: p.id, + size: p.get_size(SizeTag::Small), + lable: p.date.map(|d| d.format("%T").to_string()), + } + } + pub fn no_title(p: &Photo) -> PhotoLink { + PhotoLink { + title: None, // p.date.map(|d| d.format("%F").to_string()), + href: format!("/img/{}", p.id), + id: p.id, + size: p.get_size(SizeTag::Small), + lable: p.date.map(|d| d.format("%T").to_string()), + } + } + pub fn is_portrait(&self) -> bool { + self.size.1 > self.size.0 + } +} diff --git a/src/server/views_by_category.rs b/src/server/views_by_category.rs new file mode 100644 index 0000000..cdf2151 --- /dev/null +++ b/src/server/views_by_category.rs @@ -0,0 +1,174 @@ +//! Handle photos by tag, person, or place. +use super::render_ructe::RenderRucte; +use super::{links_by_time, not_found, Context, ImgRange}; +use crate::models::{Person, Photo, Place, Tag}; +use crate::templates; +use diesel::prelude::*; +use serde::Deserialize; +use warp::http::Response; +use warp::{reply, Reply}; + +pub fn person_all(context: Context) -> Response> { + use crate::schema::people::dsl::{id, people, person_name}; + let query = people.into_boxed(); + let query = if context.is_authorized() { + query + } else { + use crate::schema::photo_people::dsl as pp; + use crate::schema::photos::dsl as p; + query.filter(id.eq_any(pp::photo_people.select(pp::person_id).filter( + pp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), + ))) + }; + Response::builder().html(|o| { + templates::people( + o, + &context, + &query + .order(person_name) + .load(context.db()) + .expect("list people"), + ) + }) +} + +pub fn person_one( + context: Context, + tslug: String, + range: ImgRange, +) -> Response> { + use crate::schema::people::dsl::{people, slug}; + let c = context.db(); + if let Ok(person) = people.filter(slug.eq(tslug)).first::(c) { + use crate::schema::photo_people::dsl::{ + person_id, photo_id, photo_people, + }; + use crate::schema::photos::dsl::id; + let photos = Photo::query(context.is_authorized()).filter( + id.eq_any( + photo_people + .select(photo_id) + .filter(person_id.eq(person.id)), + ), + ); + let (links, coords) = links_by_time(&context, photos, range, true); + Response::builder() + .html(|o| templates::person(o, &context, &links, &coords, &person)) + } else { + not_found(&context) + } +} + +pub fn tag_all(context: Context) -> Response> { + use crate::schema::tags::dsl::{id, tag_name, tags}; + let query = tags.into_boxed(); + let query = if context.is_authorized() { + query + } else { + use crate::schema::photo_tags::dsl as tp; + use crate::schema::photos::dsl as p; + query.filter(id.eq_any(tp::photo_tags.select(tp::tag_id).filter( + tp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), + ))) + }; + Response::builder().html(|o| { + templates::tags( + o, + &context, + &query.order(tag_name).load(context.db()).expect("List tags"), + ) + }) +} + +pub fn tag_one( + context: Context, + tslug: String, + range: ImgRange, +) -> Response> { + use crate::schema::tags::dsl::{slug, tags}; + if let Ok(tag) = tags.filter(slug.eq(tslug)).first::(context.db()) { + use crate::schema::photo_tags::dsl::{photo_id, photo_tags, tag_id}; + use crate::schema::photos::dsl::id; + let photos = Photo::query(context.is_authorized()).filter( + id.eq_any(photo_tags.select(photo_id).filter(tag_id.eq(tag.id))), + ); + let (links, coords) = links_by_time(&context, photos, range, true); + Response::builder() + .html(|o| templates::tag(o, &context, &links, &coords, &tag)) + } else { + not_found(&context) + } +} + +pub fn place_all(context: Context) -> Response> { + use crate::schema::places::dsl::{id, place_name, places}; + let query = places.into_boxed(); + let query = if context.is_authorized() { + query + } else { + use crate::schema::photo_places::dsl as pp; + use crate::schema::photos::dsl as p; + query.filter(id.eq_any(pp::photo_places.select(pp::place_id).filter( + pp::photo_id.eq_any(p::photos.select(p::id).filter(p::is_public)), + ))) + }; + Response::builder().html(|o| { + templates::places( + o, + &context, + &query + .order(place_name) + .load(context.db()) + .expect("List places"), + ) + }) +} + +pub fn place_one( + context: Context, + tslug: String, + range: ImgRange, +) -> Response> { + use crate::schema::places::dsl::{places, slug}; + if let Ok(place) = + places.filter(slug.eq(tslug)).first::(context.db()) + { + use crate::schema::photo_places::dsl::{ + photo_id, photo_places, place_id, + }; + use crate::schema::photos::dsl::id; + let photos = Photo::query(context.is_authorized()).filter(id.eq_any( + photo_places.select(photo_id).filter(place_id.eq(place.id)), + )); + let (links, coord) = links_by_time(&context, photos, range, true); + Response::builder() + .html(|o| templates::place(o, &context, &links, &coord, &place)) + } else { + not_found(&context) + } +} + +pub fn auto_complete_tag(context: Context, query: AcQ) -> impl Reply { + use crate::schema::tags::dsl::{tag_name, tags}; + let q = tags + .select(tag_name) + .filter(tag_name.ilike(query.q + "%")) + .order(tag_name) + .limit(10); + reply::json(&q.load::(context.db()).unwrap()) +} + +pub fn auto_complete_person(context: Context, query: AcQ) -> impl Reply { + use crate::schema::people::dsl::{people, person_name}; + let q = people + .select(person_name) + .filter(person_name.ilike(query.q + "%")) + .order(person_name) + .limit(10); + reply::json(&q.load::(context.db()).unwrap()) +} + +#[derive(Deserialize)] +pub struct AcQ { + q: String, +} diff --git a/src/server/views_by_date.rs b/src/server/views_by_date.rs index 603befc..2a28197 100644 --- a/src/server/views_by_date.rs +++ b/src/server/views_by_date.rs @@ -1,9 +1,7 @@ use super::render_ructe::RenderRucte; use super::splitlist::links_by_time; -use super::{ - not_found, redirect_to_img, Context, ImgRange, Link, PhotoLink, SizeTag, -}; -use crate::models::Photo; +use super::{not_found, redirect_to_img, Context, ImgRange, Link, PhotoLink}; +use crate::models::{Photo, SizeTag}; use crate::templates; use chrono::naive::{NaiveDate, NaiveDateTime}; use chrono::Duration as ChDuration; diff --git a/templates/details.rs.html b/templates/details.rs.html index 4603ae2..b464264 100644 --- a/templates/details.rs.html +++ b/templates/details.rs.html @@ -1,6 +1,6 @@ @use super::page_base; -@use crate::models::{Photo, Person, Place, Tag, Camera, Coord}; -@use crate::server::{Context, Link, SizeTag}; +@use crate::models::{Photo, Person, Place, Tag, Camera, Coord, SizeTag}; +@use crate::server::{Context, Link}; @(context: &Context, lpath: &[Link], people: &[Person], places: &[Place], tags: &[Tag], position: &Option, attribution: &Option, camera: &Option, photo: &Photo) @:page_base(context, "Photo details", lpath, {