From 4c06eb64cbed3562e428ce59857d1763098638f3 Mon Sep 17 00:00:00 2001 From: Nathan Reiner Date: Wed, 19 Nov 2025 09:15:49 +0100 Subject: allow images to upload and sort if according to their datetime --- build.zig | 3 ++ src/locked.zig | 13 ++++++ src/root.zig | 1 + src/routes/api/image/list.zig | 19 ++++++-- src/routes/api/image/load.zig | 2 +- src/routes/api/image/upload.zig | 2 +- src/routes/api/profile/image/load.zig | 17 +++++++ src/routes/api/profile/image/root.zig | 5 ++ src/routes/api/profile/image/upload.zig | 19 ++++++++ src/routes/api/profile/root.zig | 1 + src/routes/api/root.zig | 1 + src/routes/api/session/current.zig | 2 + src/storage/image-manager/exif.zig | 54 ++++++++++++++++++++++ src/storage/image-manager/image.zig | 38 ++++++++++++--- src/storage/image-manager/root.zig | 79 ++++++++++++++++++++++++-------- src/storage/user.zig | 40 +++++++++++++++- static/api/images.js | 16 ++++++- static/api/index.js | 1 + static/api/profile.js | 1 + static/icon.png | Bin 0 -> 15387 bytes static/icon.svg | 72 +++++++++++++++++++++++++++++ static/index.html | 1 + static/index.js | 6 ++- static/month.js | 4 ++ static/pages/image-viewer/index.css | 4 ++ static/pages/image-viewer/index.js | 8 +++- static/pages/settings/index.css | 6 +++ static/pages/settings/index.js | 46 ++++++++++++++++--- 28 files changed, 418 insertions(+), 43 deletions(-) create mode 100644 src/locked.zig create mode 100644 src/routes/api/profile/image/load.zig create mode 100644 src/routes/api/profile/image/root.zig create mode 100644 src/routes/api/profile/image/upload.zig create mode 100644 src/routes/api/profile/root.zig create mode 100644 src/storage/image-manager/exif.zig create mode 100644 static/api/profile.js create mode 100644 static/icon.png create mode 100644 static/icon.svg diff --git a/build.zig b/build.zig index c31450c..0f3fbf1 100644 --- a/build.zig +++ b/build.zig @@ -16,6 +16,7 @@ pub fn build(b: *std.Build) void { mod.addOptions("config", opts); mod.addImport("memora", mod); + mod.linkSystemLibrary("exif", .{}); const exe = b.addExecutable(.{ .name = "memora", @@ -29,6 +30,8 @@ pub fn build(b: *std.Build) void { }), }); + exe.linkLibC(); + b.installArtifact(exe); const run_step = b.step("run", "Run the app"); diff --git a/src/locked.zig b/src/locked.zig new file mode 100644 index 0000000..6c2edfe --- /dev/null +++ b/src/locked.zig @@ -0,0 +1,13 @@ +const std = @import("std"); + +pub fn Shared(T: type) type { + return struct { + value: T, + rw_lock: *std.Thread.RwLock, + + pub fn unlock(self: *@This()) void { + self.rw_lock.unlockShared(); + self.* = undefined; + } + }; +} diff --git a/src/root.zig b/src/root.zig index 31cd751..ab7a62b 100644 --- a/src/root.zig +++ b/src/root.zig @@ -8,3 +8,4 @@ pub const Server = @import("server.zig"); pub const Storage = @import("storage/root.zig"); pub const Context = @import("context.zig"); pub const Stream = @import("stream/root.zig").Stream; +pub const locked = @import("locked.zig"); diff --git a/src/routes/api/image/list.zig b/src/routes/api/image/list.zig index d305276..5d2eace 100644 --- a/src/routes/api/image/list.zig +++ b/src/routes/api/image/list.zig @@ -7,6 +7,7 @@ pub const access = .users; const ImageInfo = struct { id: []const u8, + timestamp: ?i64, }; const Result = struct { @@ -16,13 +17,23 @@ const Result = struct { pub fn get(ctx: *Context) !Result { var images: std.ArrayList(ImageInfo) = .empty; - const images_list = ctx.storage.images.list(); - defer images_list.deinit(); + var images_list = ctx.storage.images.list(); + defer images_list.unlock(); + + var locked = ctx.storage.images.first_by_timestamp(); + defer locked.unlock(); + + var current = locked.value; + + while (current) |c| { + const image = ctx.storage.images.items.items[c.index]; - for (images_list.images) |*image| { try images.append(ctx.allocator, .{ - .id = image.id + .id = try ctx.allocator.dupe(u8, image.id), + .timestamp = image.timestamp, }); + + current = c.next(); } return .{ diff --git a/src/routes/api/image/load.zig b/src/routes/api/image/load.zig index 4f0a072..1b2a3e2 100644 --- a/src/routes/api/image/load.zig +++ b/src/routes/api/image/load.zig @@ -8,6 +8,6 @@ pub const access = .users; pub fn get(ctx: *Context) !memora.Stream { const id = ctx.request.head.target["/api/image/load/".len..]; - var image = Storage.Image { .id = id }; + var image = Storage.Image { .id = id, .timestamp = null }; return .from_file(try image.file(ctx.storage)); } diff --git a/src/routes/api/image/upload.zig b/src/routes/api/image/upload.zig index bbdd47c..72c8a2d 100644 --- a/src/routes/api/image/upload.zig +++ b/src/routes/api/image/upload.zig @@ -12,6 +12,6 @@ pub fn post(ctx: *Context) !void { if (ctx.request.head.content_length) |length| { var buffer: [1024]u8 = undefined; const reader = try ctx.request.readerExpectContinue(&buffer); - try ctx.storage.images.add(ctx.storage, reader, length); + try ctx.storage.images.save(ctx.storage, reader, length); } } diff --git a/src/routes/api/profile/image/load.zig b/src/routes/api/profile/image/load.zig new file mode 100644 index 0000000..4b696ce --- /dev/null +++ b/src/routes/api/profile/image/load.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +const memora = @import("memora"); +const Context = memora.Context; +const Storage = memora.Storage; + +pub const access = .users; + +pub fn get(ctx: *Context) !memora.Stream { + const name = ctx.request.head.target["/api/profile/image/load/".len..]; + if (Storage.User.image(ctx.storage, name)) |file| { + ctx.response.headers.content_type = "image/jpeg"; + return .from_file(file); + } + + return error.NotFound; +} diff --git a/src/routes/api/profile/image/root.zig b/src/routes/api/profile/image/root.zig new file mode 100644 index 0000000..d6b77f2 --- /dev/null +++ b/src/routes/api/profile/image/root.zig @@ -0,0 +1,5 @@ +const memora = @import("memora"); +const HandlerInfo = memora.routes.HandlerInfo; + +pub const upload: HandlerInfo = .from_type(@import("upload.zig")); +pub const load: HandlerInfo = .from_type(@import("load.zig")); diff --git a/src/routes/api/profile/image/upload.zig b/src/routes/api/profile/image/upload.zig new file mode 100644 index 0000000..db140d4 --- /dev/null +++ b/src/routes/api/profile/image/upload.zig @@ -0,0 +1,19 @@ +const std = @import("std"); + +const memora = @import("memora"); +const Context = memora.Context; +const Storage = memora.Storage; + +pub const access = .users; + +pub fn post(ctx: *Context) !void { + const name = ctx.request.head.target["/api/profile/image/upload/".len..]; + if (ctx.request.head.content_length) |length| { + var buffer: [1024]u8 = undefined; + const reader = try ctx.request.readerExpectContinue(&buffer); + + try Storage.User.set_image(ctx.storage, name, reader, length); + + ctx.response.headers.content_type = "image/jpeg"; + } +} diff --git a/src/routes/api/profile/root.zig b/src/routes/api/profile/root.zig new file mode 100644 index 0000000..632c09c --- /dev/null +++ b/src/routes/api/profile/root.zig @@ -0,0 +1 @@ +pub const image = @import("image/root.zig"); diff --git a/src/routes/api/root.zig b/src/routes/api/root.zig index 6d5745f..5f731c3 100644 --- a/src/routes/api/root.zig +++ b/src/routes/api/root.zig @@ -1,3 +1,4 @@ pub const auth = @import("auth/root.zig"); pub const session = @import("session/root.zig"); pub const image = @import("image/root.zig"); +pub const profile = @import("profile/root.zig"); diff --git a/src/routes/api/session/current.zig b/src/routes/api/session/current.zig index e86c115..897d01c 100644 --- a/src/routes/api/session/current.zig +++ b/src/routes/api/session/current.zig @@ -9,6 +9,7 @@ const Result = struct { name: []const u8, full_name: []const u8, birthday: []const u8, + is_admin: bool, }; pub fn get(ctx: *Context) !Result { @@ -21,5 +22,6 @@ pub fn get(ctx: *Context) !Result { .name = session.info.name, .full_name = session.info.full_name, .birthday = session.info.birthday, + .is_admin = session.info.is_admin, }; } diff --git a/src/storage/image-manager/exif.zig b/src/storage/image-manager/exif.zig new file mode 100644 index 0000000..02199cf --- /dev/null +++ b/src/storage/image-manager/exif.zig @@ -0,0 +1,54 @@ +const std = @import("std"); +const exif = @cImport(@cInclude("libexif/exif-data.h")); +const cstd = @cImport(@cInclude("time.h")); + +const tags = [_]c_uint { + exif.EXIF_TAG_DATE_TIME, + exif.EXIF_TAG_DATE_TIME_ORIGINAL, + exif.EXIF_TAG_DATE_TIME_DIGITIZED, +}; + +pub fn get_date_time(path: [*:0]const u8) ?i64 { + const exif_data = exif.exif_data_new_from_file(path); + defer exif.exif_data_unref(exif_data); + + if (exif_data == null) { + return null; + } + + var entry: ?[*c]exif.struct__ExifEntry = null; + for (0..exif.EXIF_IFD_COUNT) |index| { + for (tags) |tag| { + entry = exif.exif_content_get_entry((exif_data.*).ifd[index], tag); + + if (entry) |_| { break; } + } + + if (entry) |_| { break; } + } + + if (entry == null) { + return null; + } + + const c_data = (entry.?.*).data; + + return parse_date(c_data); +} + +extern fn strptime( + s: [*c]const u8, + format: [*c]const u8, + tm: *cstd.tm, +) ?*const u8; + +extern fn timegm(tm: *cstd.tm) i64; + +fn parse_date(date: [*:0]const u8) ?i64 { + var tm: cstd.tm = std.mem.zeroes(cstd.tm); + if (strptime(date, "%Y:%m:%d %H:%M:%S", &tm)) |_| { + return timegm(&tm); + } + + return null; +} diff --git a/src/storage/image-manager/image.zig b/src/storage/image-manager/image.zig index 91a094e..b11b7b1 100644 --- a/src/storage/image-manager/image.zig +++ b/src/storage/image-manager/image.zig @@ -1,6 +1,8 @@ const std = @import("std"); const Storage = @import("../root.zig"); +const exif = @import("exif.zig"); + const id_size = 32; const log = std.log.scoped(.image); @@ -19,6 +21,7 @@ fn new_id() [id_size]u8 { const Self = @This(); id: []const u8, +timestamp: ?i64, pub fn new( storage: *Storage, @@ -28,8 +31,11 @@ pub fn new( var dir = try storage.dir.openDir("image", .{}); defer dir.close(); - var file_name: [id_size+4]u8 = undefined; - const self: Self = .{ .id = &new_id() }; + var self: Self = undefined; + + self.id = try storage.allocator.dupe(u8, &new_id()); + + var file_name: [id_size + 4]u8 = undefined; @memcpy(file_name[0..id_size], self.id); @memcpy(file_name[id_size..], ".jpg"); @@ -43,14 +49,17 @@ pub fn new( try file_writer.interface.flush(); - log.info("uploaded {s} [{} bytes]", .{self.id, size}); + log.info("uploaded {s} [{} bytes]", .{ self.id, size }); + + self.timestamp = try load_timestamp(storage, self.id); return self; } -pub fn init(allocator: std.mem.Allocator, id: []const u8) !Self { +pub fn init(storage: *Storage, id: []const u8) !Self { return .{ - .id = try allocator.dupe(u8, id), + .id = try storage.allocator.dupe(u8, id), + .timestamp = try load_timestamp(storage, id), }; } @@ -61,7 +70,7 @@ pub fn file(self: *Self, storage: *Storage) !std.fs.File { }; defer dir.close(); - var file_name: [id_size+4]u8 = undefined; + var file_name: [id_size + 4]u8 = undefined; @memcpy(file_name[0..id_size], self.id); @memcpy(file_name[id_size..], ".jpg"); @@ -71,3 +80,20 @@ pub fn file(self: *Self, storage: *Storage) !std.fs.File { pub fn deinit(self: *Self, allocator: std.mem.Allocator) void { allocator.free(self.id); } + +fn load_timestamp(storage: *Storage, id: []const u8) !?i64 { + var dir = storage.dir.openDir("image", .{}) catch blk: { + try storage.dir.makeDir("image"); + break :blk try storage.dir.openDir("image", .{}); + }; + defer dir.close(); + + var file_name: [id_size + 4]u8 = undefined; + @memcpy(file_name[0..id_size], id); + @memcpy(file_name[id_size..], ".jpg"); + + var path_buffer: [std.fs.max_path_bytes:0]u8 = std.mem.zeroes([std.fs.max_path_bytes:0]u8); + const path = try dir.realpath(&file_name, &path_buffer); + + return exif.get_date_time(@ptrCast(path)); +} diff --git a/src/storage/image-manager/root.zig b/src/storage/image-manager/root.zig index 7742df2..7fa1607 100644 --- a/src/storage/image-manager/root.zig +++ b/src/storage/image-manager/root.zig @@ -1,24 +1,30 @@ const std = @import("std"); -const Storage = @import("../root.zig"); +const memora = @import("memora"); +const Storage = memora.Storage; pub const Image = @import("image.zig"); const Self = @This(); pub const empty: Self = .{}; -items: std.ArrayList(Image) = .empty, -rw_lock: std.Thread.RwLock = .{}, +const Timestamp = struct { + index: usize, + node: std.DoublyLinkedList.Node, -pub const LockedImages = struct { - images: []Image, - rw_lock: *std.Thread.RwLock, + pub fn next(self: *Timestamp) ?*Timestamp { + return @fieldParentPtr("node", self.node.next orelse return null); + } - pub fn deinit(self: *const @This()) void { - self.rw_lock.unlockShared(); + pub fn prev(self: *Timestamp) ?*Timestamp { + return @fieldParentPtr("node", self.node.prev orelse return null); } }; +items: std.ArrayList(Image) = .empty, +rw_lock: std.Thread.RwLock = .{}, +timestamp_order: std.DoublyLinkedList = .{}, + pub fn init( self: *Self, storage: *Storage, @@ -35,32 +41,67 @@ pub fn init( while (try iterator.next()) |entry| { if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".jpg")) { - try self.items.append( + try self.add( storage.allocator, - try .init(storage.allocator, entry.name[0..entry.name.len - 4]) + try .init(storage, entry.name[0..entry.name.len - 4]) ); } } } -pub fn add( +pub fn add(self: *Self, allocator: std.mem.Allocator, image: Image) !void { + self.rw_lock.lock(); + defer self.rw_lock.unlock(); + + try self.items.append( + allocator, + image, + ); + + var current = self.timestamp_order.first; + + if (current == null) { + const timestamp = try allocator.create(Timestamp); + timestamp.index = self.items.items.len - 1; + self.timestamp_order.append(×tamp.node); + return; + } + + while (current) |c| { + const index = @as(*Timestamp, @fieldParentPtr("node", c)).index; + if (image.timestamp orelse 0 > self.items.items[index].timestamp orelse 0) { + const timestamp = try allocator.create(Timestamp); + timestamp.index = self.items.items.len - 1; + self.timestamp_order.insertBefore(c, ×tamp.node); + break; + } + + current = c.next; + } +} + +pub fn save( self: *Self, storage: *Storage, reader: *std.Io.Reader, size: usize, ) !void { - self.rw_lock.lock(); - defer self.rw_lock.unlock(); + return self.add(storage.allocator, try Image.new(storage, reader, size)); +} - try self.items.append( - storage.allocator, - try Image.new(storage, reader, size), - ); +pub fn list(self: *Self) memora.locked.Shared([]Image) { + self.rw_lock.lockShared(); + return .{ + .value = self.items.items, + .rw_lock = &self.rw_lock, + }; } -pub fn list(self: *Self) LockedImages { +pub fn first_by_timestamp(self: *Self) memora.locked.Shared(?*Timestamp) { + self.rw_lock.lockShared(); return .{ - .images = self.items.items, + .value = if (self.timestamp_order.first) |first| @fieldParentPtr("node", first) + else null, .rw_lock = &self.rw_lock, }; } diff --git a/src/storage/user.zig b/src/storage/user.zig index d5260fe..09f7716 100644 --- a/src/storage/user.zig +++ b/src/storage/user.zig @@ -1,6 +1,8 @@ const std = @import("std"); const Storage = @import("root.zig"); +const log = std.log.scoped(.user); + const Self = @This(); pub const Info = struct { @@ -157,8 +159,42 @@ pub fn deinit(self: *Self) void { pub fn exists(storage: *Storage, name: []const u8) bool { var user = storage.dir.openDir("user", .{}) catch return false; - defer user.deinit(); + defer user.close(); - user.access(name) catch return false; + user.access(name, .{}) catch return false; return true; } + +pub fn image(storage: *Storage, name: []const u8) ?std.fs.File { + var user = storage.dir.openDir("user", .{}) catch return null; + defer user.close(); + + var profile = user.openDir(name, .{}) catch return null; + defer profile.close(); + return profile.openFile("image.jpg", .{ .lock = .shared }) catch null; +} + +pub fn set_image( + storage: *Storage, + name: []const u8, + reader: *std.Io.Reader, + size: usize, +) !void { + var user = try storage.dir.openDir("user", .{}); + defer user.close(); + + var profile = try user.openDir(name, .{}); + defer profile.close(); + + var image_file = try profile.createFile("image.jpg", .{ .lock = .exclusive }); + defer image_file.close(); + + var buffer: [1024]u8 = undefined; + var file_writer = image_file.writer(&buffer); + + log.info("uploaded to profile {s} [{} bytes]", .{ name, size }); + + try reader.streamExact(&file_writer.interface, size); + + try file_writer.interface.flush(); +} diff --git a/static/api/images.js b/static/api/images.js index 4bdac1f..aa783e9 100644 --- a/static/api/images.js +++ b/static/api/images.js @@ -52,15 +52,27 @@ export async function upload_to_timeline() { }) } -export async function upload_to_profile() { +export async function upload_to_profile(id) { const input = Input.new({ type: 'file', multiple: false, accept: 'image/jpeg', }) input.click(); + + return new Promise((resolve) => { + input.onchange = () => { + resolve(new FileUploader(`/api/profile/image/upload/${id}`, [...input.files])); + } + }) } export function list() { - return rest.get('/api/image/list').then(r => r.images); + return rest.get('/api/image/list') + .then(r => { + r.images.forEach(i => { + i.date = new Date(i.date * 1000) + }); + return r.images; + }); } diff --git a/static/api/index.js b/static/api/index.js index 3f2f483..cddd7bd 100644 --- a/static/api/index.js +++ b/static/api/index.js @@ -1,3 +1,4 @@ export * as images from './images.js'; export * as auth from './auth.js'; export * as session from './session.js'; +export * as profile from './profile.js'; diff --git a/static/api/profile.js b/static/api/profile.js new file mode 100644 index 0000000..a9ee591 --- /dev/null +++ b/static/api/profile.js @@ -0,0 +1 @@ +import * as rest from './rest.js'; diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..fc34cde Binary files /dev/null and b/static/icon.png differ diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..bce418c --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + M + + diff --git a/static/index.html b/static/index.html index 7450237..c69eb16 100644 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,7 @@ + diff --git a/static/index.js b/static/index.js index 2fcca88..c84aab9 100644 --- a/static/index.js +++ b/static/index.js @@ -100,7 +100,11 @@ const main = MainView.new({ main.active_kind = MainView.Kind.home; main.active_view = shuffle; }, - onsettings: () => { + onsettings: async () => { + if (!settings.profile) { + settings.profile = await api.session.current(); + } + main.active_kind = MainView.Kind.home; main.active_view = settings; }, diff --git a/static/month.js b/static/month.js index 9ed0467..7875f36 100644 --- a/static/month.js +++ b/static/month.js @@ -80,6 +80,10 @@ export default class Month { static literal(strings) { return Month.from_string(strings[0]); } + + static get current() { + return Month.from_date(new Date()); + } } export const literal = Month.literal; diff --git a/static/pages/image-viewer/index.css b/static/pages/image-viewer/index.css index 6f53333..a1a9b76 100644 --- a/static/pages/image-viewer/index.css +++ b/static/pages/image-viewer/index.css @@ -15,3 +15,7 @@ border-radius: var(--border-radius); box-shadow: #223223aa 1px 1px 4px; } + +#container img.hidden { + filter: brightness(0) invert(); +} diff --git a/static/pages/image-viewer/index.js b/static/pages/image-viewer/index.js index f65c12b..d28b3e1 100644 --- a/static/pages/image-viewer/index.js +++ b/static/pages/image-viewer/index.js @@ -15,7 +15,13 @@ export default class ImageViewer extends sfw.element.Container { } add(url) { - this.#container.append(Img.new({ src: url })); + let image; + this.#container.append( + image = Img.new({ + className: 'hidden', + src: url, + onload: () => image.classList.remove('hidden') + })); } clear() { diff --git a/static/pages/settings/index.css b/static/pages/settings/index.css index eb718ab..0d6b234 100644 --- a/static/pages/settings/index.css +++ b/static/pages/settings/index.css @@ -21,6 +21,7 @@ } #image-container { + background: #fff; position: relative; border-radius: 100%; width: 100%; @@ -31,6 +32,7 @@ } #image-container img { + visibility: hidden; position: absolute; top: 50%; left: 50%; @@ -43,6 +45,10 @@ user-select: none; } +#image-container img[src] { + visibility: visible; +} + #profile-image #edit-container { overflow: unset; } diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js index c11a3e9..f316e8e 100644 --- a/static/pages/settings/index.js +++ b/static/pages/settings/index.js @@ -1,6 +1,8 @@ import * as sfw from 'sfw'; const { Div, Img } = sfw.element.native; +import * as api from '../../api/index.js'; + import Editable from '../../widgets/editable/index.js'; import icons from '../../icons/index.js'; @@ -8,6 +10,12 @@ import icons from '../../icons/index.js'; const css = await sfw.css(import.meta.url, './index.css'); export default class SettingsView extends sfw.element.Container { + #profile_image + #name + #birthday + + #profile + constructor() { super({ css }); @@ -23,23 +31,35 @@ export default class SettingsView extends sfw.element.Container { Div.new({ id: 'image-container', children: [ - Img.new(), + this.#profile_image = Img.new(), ] }), Div.new({ id: 'edit', - children: [ icons.edit ] + children: [ icons.edit ], + onclick: async () => { + const uploader = await api.images.upload_to_profile(this.#profile.name); + uploader.ondone = async () => { + const blob = await fetch( + `/api/profile/image/load/${this.#profile.name}`, + {cache: 'reload', mode: 'no-cors'} + ).then(r => r.blob()); + + this.#profile_image.src = URL.createObjectURL(blob); + }; + uploader.send() + } }), ] }), - Editable.new({ + this.#name = Editable.new({ title: 'Name', - value: 'Nathan Reiner' + value: '' }), - Editable.new({ + this.#birthday = Editable.new({ title: 'Birthday', type: 'date', - value: '2002-08-06', + value: '', }), Div.new({ id: 'logout', @@ -57,4 +77,18 @@ export default class SettingsView extends sfw.element.Container { }) ) } + + set profile(profile) { + this.#profile = profile; + + this.#profile_image.src = `/api/profile/image/load/${profile.name}`; + this.#profile_image.onerror = () => this.#profile_image.removeAttribute('src'); + + this.#name.value = profile.full_name; + this.#birthday.value = profile.birthday; + } + + get profile() { + return this.#profile; + } } -- cgit v1.2.3-70-g09d2