├── svelte ├── .npmrc ├── src │ ├── routes │ │ ├── +layout.js │ │ ├── +page.ts │ │ └── +page.svelte │ ├── lib │ │ ├── index.ts │ │ └── time.ts │ ├── app.d.ts │ └── app.html ├── .prettierignore ├── static │ └── favicon.png ├── vite.config.ts ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── svelte.config.js ├── package.json └── serve-build.py ├── fileserve ├── Makefile ├── src │ ├── lib.rs │ ├── http.rs │ ├── models.rs │ ├── main.rs │ ├── db.rs │ └── router.rs └── Cargo.toml ├── fswatch ├── Makefile ├── Cargo.toml └── src │ └── main.rs ├── Cargo.toml ├── .gitignore ├── Makefile └── shared ├── Cargo.toml └── src ├── lib.rs ├── config.rs ├── hash.rs └── image.rs /svelte/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /fileserve/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | cargo run 3 | -------------------------------------------------------------------------------- /fswatch/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | cargo run 3 | -------------------------------------------------------------------------------- /svelte/src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["fswatch", "fileserve", "shared"] 4 | -------------------------------------------------------------------------------- /fileserve/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub mod http; 3 | pub mod models; 4 | pub mod router; 5 | -------------------------------------------------------------------------------- /svelte/.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /svelte/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ignore 2 | target/ 3 | Cargo.lock 4 | *.sqlite 5 | img/ 6 | *.yaml 7 | photobackup-client/ 8 | -------------------------------------------------------------------------------- /svelte/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donpdonp/img-gallery/main/svelte/static/favicon.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all web 2 | 3 | all: 4 | cargo build 5 | 6 | web: 7 | cd svelte ; npm run build 8 | 9 | webdev: 10 | cd svelte ; python serve-build.py 11 | -------------------------------------------------------------------------------- /svelte/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | figment = { version = "0.10.19", features = ["yaml"] } 8 | serde = "1.0.214" 9 | data-encoding = "2.6.0" 10 | image = "0.25.5" 11 | -------------------------------------------------------------------------------- /shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::sync::OnceLock; 2 | 3 | pub mod config; 4 | pub mod hash; 5 | pub mod image; 6 | 7 | pub const CONFIG_FILE: &str = "config.yaml"; 8 | pub static CONFIG: OnceLock = OnceLock::new(); 9 | 10 | #[cfg(test)] 11 | mod tests {} 12 | -------------------------------------------------------------------------------- /svelte/src/routes/+page.ts: -------------------------------------------------------------------------------- 1 | import type { PageLoad } from './$types'; 2 | import { browser } from '$app/environment'; 3 | 4 | export const load = async ({ fetch, params, url }) => { 5 | if (browser) { 6 | const since = url.searchParams.get('since') || 1; 7 | return { since: since }; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /svelte/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | 4 | # Output 5 | .output 6 | .vercel 7 | /.svelte-kit 8 | /build 9 | 10 | # OS 11 | .DS_Store 12 | Thumbs.db 13 | 14 | # Env 15 | .env 16 | .env.* 17 | !.env.example 18 | !.env.test 19 | 20 | # Vite 21 | vite.config.js.timestamp-* 22 | vite.config.ts.timestamp-* 23 | -------------------------------------------------------------------------------- /svelte/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": [ 7 | "prettier-plugin-svelte" 8 | ], 9 | "overrides": [ 10 | { 11 | "files": "*.svelte", 12 | "options": { 13 | "parser": "svelte" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /fileserve/src/http.rs: -------------------------------------------------------------------------------- 1 | use tiny_http::Request; 2 | 3 | pub fn parse_request(request: &mut Request) -> Option { 4 | if let Some(_) = request.body_length() { 5 | let mut json = String::new(); 6 | request.as_reader().read_to_string(&mut json).unwrap(); 7 | return Some(json); 8 | } 9 | None 10 | } 11 | -------------------------------------------------------------------------------- /svelte/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /fileserve/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fileserve" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | shared = {path = "../shared"} 8 | serde = { version = "1.0.214", features = ["derive"] } 9 | serde_json = "1.0.132" 10 | sqlite = "0.36.1" 11 | tiny_http = "0.12.0" 12 | url = "2.5.4" 13 | multipart = { version = "0.18.0", features = ["server"] } 14 | 15 | -------------------------------------------------------------------------------- /fswatch/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fswatch" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | notify = { version = "6.1.1", features = ["serde"] } 8 | sqlite = "0.36.1" 9 | fileserve = {path = "../fileserve"} 10 | shared = {path = "../shared"} 11 | highway = "1.2.0" 12 | kamadak-exif = "0.6.1" 13 | time = { version = "0.3.36", features = ["parsing"] } 14 | chrono = "0.4.39" 15 | -------------------------------------------------------------------------------- /shared/src/config.rs: -------------------------------------------------------------------------------- 1 | use figment::{ 2 | providers::{Format, Yaml}, 3 | Figment, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Serialize, Deserialize)] 8 | pub struct Config { 9 | pub photos_path: String, 10 | pub listen_address: String, 11 | } 12 | 13 | pub fn load(filename: &str) -> Config { 14 | Figment::new() 15 | .merge(Yaml::file(filename)) 16 | .extract() 17 | .unwrap() 18 | } 19 | -------------------------------------------------------------------------------- /svelte/src/lib/time.ts: -------------------------------------------------------------------------------- 1 | export function groups(group_count: number): [] { 2 | let now = Math.trunc(new Date().getTime() / 1000); 3 | let day = 24 * 60 * 60; 4 | let groups = []; 5 | for (let i = 0; i < group_count; i++) { 6 | let ago = day * 7; 7 | 8 | let start = now - ago * (i + 1); 9 | let stop = now - ago * i; 10 | groups.push([start, stop]); 11 | } 12 | return { start: groups[groups.length - 1][0], stop: now, groups: groups }; 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/hash.rs: -------------------------------------------------------------------------------- 1 | use data_encoding::BASE64URL_NOPAD; 2 | use serde::{Serialize, Serializer}; 3 | 4 | pub fn hash_to_u64(hash_code: &str) -> u64 { 5 | let u64_bytes = BASE64URL_NOPAD.decode(hash_code.as_bytes()).unwrap(); 6 | u64::from_le_bytes(u64_bytes[0..8].try_into().unwrap()) 7 | } 8 | 9 | pub fn u64_to_hash(v: &u64, serializer: S) -> Result { 10 | BASE64URL_NOPAD 11 | .encode(&v.to_le_bytes().to_vec()) 12 | .serialize(serializer) 13 | } 14 | -------------------------------------------------------------------------------- /svelte/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 15 | 16 |
%sveltekit.body%
17 | 18 | 19 | -------------------------------------------------------------------------------- /svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /svelte/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://svelte.dev/docs/kit/integrations 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /fileserve/src/models.rs: -------------------------------------------------------------------------------- 1 | #[derive(serde::Serialize, serde::Deserialize)] 2 | pub struct Image { 3 | pub filename: String, 4 | #[serde(serialize_with = "shared::hash::u64_to_hash")] 5 | pub hash: u64, 6 | pub dim: (u32, u32), 7 | pub datetime: u64, 8 | } 9 | 10 | impl Image { 11 | pub(crate) fn from_statement(statement: &sqlite::Statement<'_>) -> Image { 12 | let dim = ( 13 | statement.read::("dim_x").unwrap() as u32, 14 | statement.read::("dim_y").unwrap() as u32, 15 | ); 16 | Image { 17 | hash: statement.read::("hash").unwrap() as u64, 18 | filename: statement.read::("filename").unwrap(), 19 | dim, 20 | datetime: statement.read::("datetime").unwrap() as u64, 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dpi", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "format": "prettier --write .", 12 | "lint": "prettier --check ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.0.0", 16 | "@sveltejs/adapter-static": "^3.0.6", 17 | "@sveltejs/kit": "^2.0.0", 18 | "@sveltejs/vite-plugin-svelte": "^4.0.0", 19 | "prettier": "^3.3.2", 20 | "prettier-plugin-svelte": "^3.2.6", 21 | "svelte": "^5.0.0", 22 | "svelte-check": "^4.0.0", 23 | "svelte-time": "^0.9.0", 24 | "typescript": "^5.0.0", 25 | "vite": "^5.0.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /fileserve/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::thread::spawn; 2 | 3 | use tiny_http::{Header, Server}; 4 | 5 | use fileserve::{db, router::route_request}; 6 | 7 | fn main() -> Result<(), std::io::Error> { 8 | println!("config {}", shared::CONFIG_FILE); 9 | shared::CONFIG 10 | .set(shared::config::load(shared::CONFIG_FILE)) 11 | .unwrap(); 12 | let config = shared::CONFIG.get().unwrap(); 13 | 14 | println!("listening {}", &config.listen_address); 15 | let server = Server::http(&config.listen_address).unwrap(); 16 | 17 | // accept connections and process them serially 18 | for mut request in server.incoming_requests() { 19 | println!("method: {:?}, url: {:?}", request.method(), request.url(),); 20 | 21 | spawn(|| { 22 | let mut db = db::init(); 23 | let resp = route_request(&mut db, &mut request); 24 | let cors_origin = Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap(); 25 | let cors_headers = 26 | Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap(); 27 | request 28 | .respond(resp.with_header(cors_origin).with_header(cors_headers)) 29 | .unwrap(); 30 | }); 31 | } 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/image.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | 3 | use image::{GenericImageView, ImageError, ImageReader}; 4 | 5 | pub fn image_dimensions(bytes: &Vec) -> (u32, u32) { 6 | let img = ImageReader::new(Cursor::new(bytes)) 7 | .with_guessed_format() 8 | .unwrap() 9 | .decode() 10 | .unwrap(); 11 | img.dimensions() 12 | } 13 | 14 | pub fn image_thumb(bytes: &Vec, height_opt: Option) -> Result, ImageError> { 15 | let img = ImageReader::new(Cursor::new(bytes)) 16 | .with_guessed_format()? 17 | .decode()?; 18 | let dim = img.dimensions(); 19 | let (computed_width, computed_height) = if let Some(height) = height_opt { 20 | ( 21 | (dim.0 as f32 * (height as f32 / dim.1 as f32)) as u32, 22 | height, 23 | ) 24 | } else { 25 | (dim.0 as u32, dim.1 as u32) 26 | }; 27 | println!( 28 | "requested height {:?} resize {},{} -> {},{}", 29 | height_opt, dim.0, dim.1, computed_width, computed_height 30 | ); 31 | let thumbnail = img.resize( 32 | computed_width, 33 | computed_height, 34 | image::imageops::FilterType::Triangle, 35 | ); 36 | let mut bytes: Vec = Vec::new(); 37 | thumbnail.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Jpeg)?; 38 | Ok(bytes) 39 | } 40 | -------------------------------------------------------------------------------- /svelte/serve-build.py: -------------------------------------------------------------------------------- 1 | from http.server import HTTPServer, BaseHTTPRequestHandler 2 | import pathlib 3 | 4 | class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): 5 | 6 | def do_GET(self): 7 | self.path = "/build" + self.path.split('?')[0] 8 | path = pathlib.Path(self.path) 9 | if path.suffix == '': 10 | self.path = '/build/index.html' 11 | path = pathlib.Path(self.path) 12 | try: 13 | mime = extToMime(path.suffix) 14 | file_to_open = open(self.path[1:], mode="rb").read() 15 | self.send_response(200) 16 | self.send_header('Content-type', mime) 17 | self.end_headers() 18 | self.wfile.write(file_to_open) 19 | except Exception as e: 20 | print(f"exception {path}: {e}") 21 | self.send_response(404) 22 | self.send_header('Content-type', 'text/html') 23 | self.end_headers() 24 | self.wfile.write(b'404 - Not Found') 25 | 26 | def extToMime(extension): 27 | mime = 'text/plain' 28 | match extension[1:]: 29 | case 'css': 30 | mime = 'text/css' 31 | case 'html': 32 | mime = 'text/html' 33 | case 'js': 34 | mime = 'text/javascript' 35 | case 'png': 36 | mime = 'image/png' 37 | return mime 38 | 39 | httpd = HTTPServer(('', 8000), SimpleHTTPRequestHandler) 40 | print(f'http://{httpd.server_address[0]}:{httpd.server_port}') 41 | httpd.serve_forever() 42 | -------------------------------------------------------------------------------- /svelte/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | {#if loading} 39 | Gathering images... 40 | {:else} 41 |
46 | 47 |
48 | {#each image_groups.groups as image_group} 49 |
50 | 51 | 53 | {#if image_group[1].length > 0} 54 | {image_group[1].length} pics 55 | {/if} 56 |
57 |
58 | {#each image_group[1] as image} 59 |
60 | 61 | {image.filename} 67 | 68 |
69 | {image.dim[0]}x{image.dim[1]} 70 |
72 |
73 | {/each} 74 |
75 | {/each} 76 |
77 | 78 | 97 | -------------------------------------------------------------------------------- /fileserve/src/db.rs: -------------------------------------------------------------------------------- 1 | use sqlite::{Connection, State}; 2 | 3 | use crate::models::Image; 4 | 5 | pub fn init() -> Connection { 6 | let filepath = std::fs::canonicalize("images.sqlite").unwrap(); 7 | println!("sqlite3 {}", filepath.as_os_str().to_str().unwrap()); 8 | let connection = sqlite::open(filepath).unwrap(); 9 | connection 10 | .execute( 11 | "CREATE TABLE IF NOT EXISTS images ( 12 | hash INTEGER PRIMARY KEY, 13 | filename VARCHAR(255), 14 | dim_x INTEGER, 15 | dim_y INTEGER, 16 | datetime INTEGER 17 | )", 18 | ) 19 | .unwrap(); 20 | connection 21 | } 22 | 23 | pub fn images_since(db: &mut Connection, start_timestamp: u64, stop_timestamp: u64) -> Vec { 24 | let mut images: Vec = Vec::new(); 25 | let sql = "select * from images where datetime >= ? and datetime < ?"; 26 | println!( 27 | "images_since: {} {} {}", 28 | sql, start_timestamp, stop_timestamp 29 | ); 30 | let mut stmt = db.prepare(sql).unwrap(); 31 | stmt.bind((1, start_timestamp as i64)).unwrap(); 32 | stmt.bind((2, stop_timestamp as i64)).unwrap(); 33 | while let Ok(State::Row) = stmt.next() { 34 | let img: Image = Image::from_statement(&stmt); 35 | images.push(img) 36 | } 37 | images 38 | } 39 | 40 | pub fn image_exists(db: &mut Connection, hash: u64) -> Option { 41 | let mut stmt = db.prepare("SELECT * FROM images WHERE hash = ?").unwrap(); 42 | stmt.bind((1, hash as i64)).unwrap(); 43 | if let Ok(State::Row) = stmt.next() { 44 | Some(Image::from_statement(&stmt)) 45 | } else { 46 | None 47 | } 48 | } 49 | 50 | pub fn image_insert(c: &mut Connection, image: &Image) { 51 | let mut stmt = c 52 | .prepare( 53 | "INSERT INTO images (hash, filename, dim_x, dim_y, datetime) VALUES (?, ?, ?, ?, ?)", 54 | ) 55 | .unwrap(); 56 | stmt.bind((1, image.hash as i64)).unwrap(); 57 | stmt.bind((2, image.filename.as_str())).unwrap(); 58 | stmt.bind((3, image.dim.0 as i64)).unwrap(); 59 | stmt.bind((4, image.dim.1 as i64)).unwrap(); 60 | stmt.bind((5, image.datetime as i64)).unwrap(); 61 | loop { 62 | match stmt.next() { 63 | Ok(row) => { 64 | if row == State::Done { 65 | break; 66 | } 67 | } 68 | Err(err) => { 69 | println!("{:?}", err); 70 | match err.code { 71 | Some(code) => { 72 | if code == 19 { 73 | println!("hash crash!") 74 | } 75 | } 76 | None => (), 77 | } 78 | break; 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /fswatch/src/main.rs: -------------------------------------------------------------------------------- 1 | use exif::{Exif, In, Tag}; 2 | use fileserve::db; 3 | use fileserve::models::Image; 4 | use highway::{HighwayHash, PortableHash}; 5 | use notify::{Event, RecursiveMode, Result, Watcher}; 6 | use shared::image::image_dimensions; 7 | use std::io::Cursor; 8 | use std::path::{Path, PathBuf}; 9 | use std::sync::mpsc; 10 | use time::{format_description, OffsetDateTime}; 11 | 12 | fn main() -> Result<()> { 13 | let (tx, rx) = mpsc::channel::>(); 14 | println!("config {}", shared::CONFIG_FILE); 15 | shared::CONFIG 16 | .set(shared::config::load(shared::CONFIG_FILE)) 17 | .unwrap(); 18 | let config = shared::CONFIG.get().unwrap(); 19 | 20 | let mut watcher = notify::recommended_watcher(tx)?; 21 | println!("photos_path {}", &config.photos_path); 22 | watcher.watch(Path::new(&config.photos_path), RecursiveMode::Recursive)?; 23 | 24 | for res in rx { 25 | match res { 26 | Ok(event) => match event.kind { 27 | notify::EventKind::Access(notify::event::AccessKind::Close( 28 | notify::event::AccessMode::Write, 29 | )) => sync(event.paths[0].clone()), 30 | _ => (), 31 | }, 32 | Err(e) => println!("watch error: {:?}", e), 33 | } 34 | } 35 | 36 | Ok(()) 37 | } 38 | 39 | fn sync(path: std::path::PathBuf) { 40 | let file_bytes = std::fs::read(&path).unwrap(); 41 | let hash = PortableHash::default().hash64(&file_bytes); 42 | let mut db = db::init(); 43 | if let Some(_image) = fileserve::db::image_exists(&mut db, hash) { 44 | println!( 45 | "{:?} (len {}) skipping. hash exists {}", 46 | path, 47 | file_bytes.len(), 48 | hash 49 | ); 50 | } else { 51 | println!("{:?} analyzing", path); 52 | let image = image_analysis(&path, &file_bytes, hash); 53 | println!( 54 | "{:?} inserting {} hash {}", 55 | path, 56 | hash, 57 | chrono::Utc::from(image.datetime) 58 | ); 59 | fileserve::db::image_insert(&mut db, &image); 60 | } 61 | } 62 | 63 | fn image_analysis(path: &PathBuf, bytes: &Vec, hash: u64) -> Image { 64 | let filename = String::from_utf8(Vec::from( 65 | path.as_path().file_name().unwrap().as_encoded_bytes(), 66 | )) 67 | .unwrap(); 68 | let dim = image_dimensions(&bytes); 69 | let datetime = image_datetime(&bytes); 70 | //let latlng = exif_latlng_extract(&exif); 71 | Image { 72 | filename, 73 | hash, 74 | dim, 75 | datetime, 76 | } 77 | } 78 | 79 | fn image_datetime(bytes: &Vec) -> u64 { 80 | let reader = exif::Reader::new(); 81 | match reader.read_from_container(&mut Cursor::new(bytes)) { 82 | Ok(exif) => exif_date_extract(&exif).unix_timestamp() as u64, 83 | Err(_) => chrono::Utc::now().timestamp_millis() as u64, 84 | } 85 | } 86 | 87 | fn exif_latlng_extract(exif: &Exif) -> (i32, i32) { 88 | // "GPSLatitudeRef" Ascii(["N"]) 89 | let gps_lat_ref_field = exif.get_field(Tag::GPSLatitudeRef, In::PRIMARY).unwrap(); 90 | // "GPSLatitude" Rational([Rational(13/1), Rational(45/1), Rational(461425/10000)]) 91 | let gps_lat_field = exif.get_field(Tag::GPSLatitude, In::PRIMARY).unwrap(); 92 | let lat = if let exif::Value::Rational(gps_lat) = &gps_lat_field.value { 93 | gps_lat[0].to_f32() 94 | } else { 95 | Option::None.unwrap() 96 | }; 97 | // "GPSLongitudeRef" Ascii(["E"]) 98 | let gps_lon_ref_field = exif.get_field(Tag::GPSLongitudeRef, In::PRIMARY).unwrap(); 99 | // "GPSLongitude" Rational([Rational(100/1), Rational(33/1), Rational(523608/10000)]) 100 | let gps_lon_field = exif.get_field(Tag::GPSLongitude, In::PRIMARY).unwrap(); 101 | println!( 102 | "{:?} {:?} {:?}", 103 | gps_lat_field.value, 104 | gps_lat_field.display_value().to_string(), 105 | lat 106 | ); 107 | (0, 0) 108 | } 109 | 110 | fn exif_date_extract(exif: &Exif) -> OffsetDateTime { 111 | // DateTimeOriginal" Ascii(["2024:11:18 12:48:17"]) 112 | let photo_datetime_field = exif.get_field(Tag::DateTime, In::PRIMARY).unwrap(); 113 | // "OffsetTime" Ascii(["+07:00"]) 114 | let photo_timezone_field = exif.get_field(Tag::OffsetTime, In::PRIMARY).unwrap(); 115 | let photo_timezone_str = photo_timezone_field 116 | .value 117 | .display_as(Tag::OffsetTime) 118 | .to_string(); 119 | let photo_timezone_wtf = photo_timezone_str 120 | .strip_prefix('"') // why 121 | .unwrap() 122 | .strip_suffix('"') 123 | .unwrap(); 124 | let fulldate = format!( 125 | "{} {}", 126 | photo_datetime_field.value.display_as(Tag::DateTime), 127 | photo_timezone_wtf, 128 | ); 129 | // "2024-11-18 12:48:17 +07:00" 130 | let format = format_description::parse( 131 | "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory]:[offset_minute]" ).unwrap(); 132 | OffsetDateTime::parse(&fulldate, &format).unwrap() 133 | } 134 | 135 | #[cfg(test)] 136 | mod tests { 137 | use super::*; 138 | 139 | #[test] 140 | fn it_works() { 141 | let bytes = vec![]; 142 | let exif = exif::Reader::new() 143 | .read_from_container(&mut Cursor::new(bytes)) 144 | .unwrap(); 145 | assert_eq!(exif_date_extract(&exif), OffsetDateTime::now_utc()); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /fileserve/src/router.rs: -------------------------------------------------------------------------------- 1 | use std::io::{Cursor, Read}; 2 | 3 | use multipart::server::{HttpRequest, Multipart, ReadEntry}; 4 | use shared::image::image_thumb; 5 | use sqlite::Connection; 6 | use tiny_http::{Header, Method, Request, Response}; 7 | use url::Url; 8 | 9 | use crate::{db, http::parse_request, models::Image}; 10 | 11 | #[derive(serde::Deserialize)] 12 | pub struct Req { 13 | start_timestamp: u64, 14 | stop_timestamp: u64, 15 | } 16 | 17 | #[derive(serde::Serialize)] 18 | pub struct ErrorResp {} 19 | 20 | #[derive(serde::Serialize)] 21 | pub struct ImageListResp { 22 | images: Vec, 23 | } 24 | 25 | pub struct TinyHttpRequest<'s> { 26 | request: &'s mut Request, 27 | } 28 | 29 | impl<'r> HttpRequest for TinyHttpRequest<'r> { 30 | type Body = &'r mut dyn Read; 31 | 32 | fn multipart_boundary(&self) -> Option<&str> { 33 | const BOUNDARY: &str = "boundary="; 34 | 35 | let content_type = self 36 | .request 37 | .headers() 38 | .iter() 39 | .find(|header| header.field.equiv("Content-Type")) 40 | .unwrap() 41 | .value 42 | .as_str(); 43 | let start = content_type.find(BOUNDARY).unwrap() + BOUNDARY.len(); 44 | let end = content_type[start..] 45 | .find(';') 46 | .map_or(content_type.len(), |end| start + end); 47 | 48 | Some(&content_type[start..end]) 49 | } 50 | 51 | fn body(self) -> Self::Body { 52 | self.request.as_reader() 53 | } 54 | } 55 | 56 | pub fn route_request<'r>( 57 | db: &mut Connection, 58 | request: &'r mut Request, 59 | ) -> Response>> { 60 | match request.method() { 61 | Method::Post => { 62 | // route: POST content-type: multipart/form-data; boundary=4e204ab2-6e27-4f6d-a91d-6367dc6168da 63 | let headers = request.headers(); 64 | let content_type = headers 65 | .iter() 66 | .find(|h| h.field.equiv("content-type")) 67 | .unwrap(); 68 | let ctv = content_type.value.to_string(); 69 | let ctc = ctv.split(';').collect::>()[0]; 70 | if ctc == "multipart/form-data" { 71 | let trequest = TinyHttpRequest { request }; // container to make multipart-rs happy with local tiny_http 72 | let body = match Multipart::from_request(trequest) { 73 | Ok(multipart) => save_multipart(multipart), 74 | Err(req) => format!("multipart err {}", req.request.url()), 75 | }; 76 | Response::from_string(body) 77 | } else if ctc == "application/json" { 78 | let json_opt = parse_request(request); 79 | if let Some(json) = json_opt { 80 | println!("body: {}", json); 81 | let req: Req = serde_json::from_str(&json).unwrap(); 82 | image_gallery(db, req) 83 | } else { 84 | let err_req = serde_json::to_string(&ErrorResp {}).unwrap(); 85 | Response::from_string(err_req) 86 | } 87 | } else { 88 | Response::from_string("unknown mimetype") 89 | } 90 | } 91 | Method::Get => { 92 | if request.url() == "/test" { 93 | return Response::from_string("").with_status_code(200); 94 | } else { 95 | thumbnail(db, request) 96 | } 97 | } 98 | Method::Options => Response::from_string("").with_status_code(200), 99 | 100 | _ => Response::from_string("").with_status_code(200), 101 | } 102 | } 103 | 104 | fn save_multipart(multipart: Multipart<&mut dyn Read>) -> String { 105 | let config = shared::CONFIG.get().unwrap(); 106 | let mut filename: Option = None; 107 | let mut entry_result = multipart.read_entry(); 108 | loop { 109 | match entry_result { 110 | multipart::server::ReadEntryResult::Entry(mut entry) => { 111 | println!("entry {:?}", entry.headers); 112 | if *entry.headers.name == *"upfile" { 113 | match entry.data.save().with_dir(config.photos_path.clone()) { 114 | multipart::server::SaveResult::Full(save_result) => match save_result { 115 | multipart::server::save::SavedData::Text(_) => { 116 | todo!() 117 | } 118 | multipart::server::save::SavedData::Bytes(_) => { 119 | todo!() 120 | } 121 | multipart::server::save::SavedData::File(filename_str, _) => { 122 | println!("fullsave: {:?}", filename_str); 123 | filename = Some( 124 | filename_str.into_os_string().to_string_lossy().into_owned(), 125 | ); 126 | } 127 | }, 128 | multipart::server::SaveResult::Partial(_, _) => todo!(), 129 | multipart::server::SaveResult::Error(_) => todo!(), 130 | } 131 | } 132 | entry_result = entry.next_entry(); 133 | } 134 | multipart::server::ReadEntryResult::End(_) => break, 135 | multipart::server::ReadEntryResult::Error(_, _) => break, 136 | } 137 | } 138 | filename.unwrap_or("moar err".to_owned()) 139 | } 140 | 141 | fn image_gallery(db: &mut Connection, req: Req) -> Response>> { 142 | let images = db::images_since(db, req.start_timestamp, req.stop_timestamp); 143 | println!("image gallery metadata for {}", images.len()); 144 | let req_resp = ImageListResp { images }; 145 | let json = serde_json::to_string(&req_resp).unwrap(); 146 | let content_type = Header::from_bytes("Content-Type", "application/json").unwrap(); 147 | Response::from_string(json).with_header(content_type) 148 | } 149 | 150 | fn thumbnail(db: &mut Connection, request: &mut Request) -> Response>> { 151 | let url = Url::parse(&("http://localhost".to_owned() + request.url())).expect("bad url"); 152 | let hash_code = url.path(); 153 | let hash = shared::hash::hash_to_u64(&hash_code[1..]); 154 | let config = shared::CONFIG.get().unwrap(); 155 | let img_bytes = match db::image_exists(db, hash) { 156 | Some(image) => { 157 | let new_height = url 158 | .query_pairs() 159 | .find(|qp| qp.0 == "h") 160 | .map(|qp| u32::from_str_radix(&qp.1, 10).unwrap()); 161 | let filename = config.photos_path.clone() + "/" + &image.filename; 162 | println!("thumbnail processing {:?}", filename); 163 | let file_bytes = std::fs::read(filename).unwrap(); 164 | image_thumb(&file_bytes, new_height).unwrap() 165 | } 166 | None => vec![], 167 | }; 168 | let content_type = Header::from_bytes("Content-Type", "image/jpeg").unwrap(); 169 | Response::from_data(img_bytes).with_header(content_type) 170 | } 171 | --------------------------------------------------------------------------------