//! The senders' photos from Gravatar, for a reader's avatars: kept in //! the cache directory, by the SHA-256 of the address Gravatar names //! them by, and in memory, decoded, while the app runs. A photo kept //! less than a week shows without asking again; an older one shows //! until the reader asks the web for it anew. Gravatar's 404, which a //! network failure looks the same as, is kept only for the run, so one //! run offline does not cost every photo for a week. const std = @import("std"); const siv = @import("siv"); const ascii = std.ascii; const mem = std.mem; const Allocator = std.mem.Allocator; const Io = std.Io; const Sha256 = std.crypto.hash.sha2.Sha256; const log = std.log.scoped(.avatars); const testing = std.testing; const Avatars = @This(); gpa: Allocator, io: Io, window: siv.Window, /// Where the photos are kept; empty keeps none, as in tests. dir: []const u8, faces: std.array_hash_map.Auto(Hash, Face), /// Hears that a photo was decoded, so the avatars show it. changed: Changed, /// An address as Gravatar names it, in lowercase hex. pub const Hash = [Sha256.digest_length * 2]u8; pub const Changed = struct { context: *anyopaque, call: *const fn (context: *anyopaque) void, }; /// The photo's size asked of Gravatar, in pixels: the larger avatar at /// a scale of 2 and more. const asked_px = 64; /// How long a kept photo shows without asking for it again. const fresh_days = 7; const Face = struct { /// The photo decoded, or null while there is none. image: siv.Image, /// The decode of one read or come, which replaces `image`. request: siv.ImageRequest, /// Whether it was kept less than `fresh_days` ago. fresh: bool, /// Whether the web was asked this run. asked: bool, }; /// What a decode needs to find its face once done. const Load = struct { avatars: *Avatars, hash: Hash }; pub fn init(gpa: Allocator, io: Io, window: siv.Window, changed: Changed) Avatars { return .{ .gpa = gpa, .io = io, .window = window, .dir = "", .faces = .empty, .changed = changed }; } pub fn deinit(self: *Avatars) void { for (self.faces.values()) |f| { if (f.request != .null) siv.imageRequestCancel(f.request); if (f.image != .null) siv.imageDestroy(f.image); } self.faces.deinit(self.gpa); self.* = undefined; } /// How Gravatar names `address`: the SHA-256 of it trimmed and in /// lowercase, in hex. pub fn hash(address: []const u8) Hash { var buf: [320]u8 = undefined; const trimmed = mem.trim(u8, address, " \t"); const lower = ascii.lowerString(buf[0..@min(trimmed.len, buf.len)], trimmed[0..@min(trimmed.len, buf.len)]); var digest: [Sha256.digest_length]u8 = undefined; Sha256.hash(lower, &digest, .{}); return std.fmt.bytesToHex(digest, .lower); } /// The photo of the address `h` names, once decoded; starts reading it /// from the cache the first time it is asked for. pub fn image(self: *Avatars, h: Hash) ?siv.Image { const got = self.faces.getOrPut(self.gpa, h) catch return null; if (!got.found_existing) { got.value_ptr.* = .{ .image = .null, .request = .null, .fresh = false, .asked = false }; self.read(h, got.value_ptr); } const f = got.value_ptr.*; return if (f.image == .null) null else f.image; } /// Whether the web should be asked for `h`'s photo: not asked this run, /// and none kept, or none kept lately. `image` asks the cache first. pub fn wanted(self: *const Avatars, h: Hash) bool { const f = self.faces.get(h) orelse return false; return !f.asked and !f.fresh; } /// Marks `h` asked of the web, and returns where, in `buf`. pub fn ask(self: *Avatars, h: Hash, buf: []u8) ![]const u8 { if (self.faces.getPtr(h)) |f| f.asked = true; return mem.print(buf, "https://gravatar.com/avatar/{s}?s={d}&d=404", .{ &h, asked_px }); } /// What the web said of `h`: the photo's bytes, or null when it has /// none or could not be asked. pub fn came(self: *Avatars, h: Hash, bytes: ?[]const u8) void { const data = bytes orelse return; const f = self.faces.getPtr(h) orelse return; f.fresh = true; self.keep(h, data); self.decode(h, f, .{ .data = data }); } fn read(self: *Avatars, h: Hash, f: *Face) void { if (self.dir.len == 0) return; var path_buf: [Io.Dir.max_path_bytes]u8 = undefined; const path = mem.print(&path_buf, "{s}/{s}", .{ self.dir, &h }) catch return; const stat = Io.Dir.cwd().statFile(self.io, path, .{}) catch return; const age = stat.mtime.durationTo(Io.Timestamp.now(self.io, .real)).toSeconds(); f.fresh = age < fresh_days * std.time.s_per_day; self.decode(h, f, .{ .path = path }); } fn keep(self: *Avatars, h: Hash, data: []const u8) void { if (self.dir.len == 0) return; var dir = Io.Dir.cwd().createDirPathOpen(self.io, self.dir, .{}) catch |err| return log.warn("{s}: {t}", .{ self.dir, err }); defer dir.close(self.io); dir.writeFile(self.io, .{ .sub_path = &h, .data = data }) catch |err| log.warn("keeping a photo: {t}", .{err}); } const Source = union(enum) { path: []const u8, data: []const u8 }; fn decode(self: *Avatars, h: Hash, f: *Face, source: Source) void { if (f.request != .null) siv.imageRequestCancel(f.request); f.request = .null; const load = self.gpa.create(Load) catch return; load.* = .{ .avatars = self, .hash = h }; const scale: f32 = @floatCast(siv.windowGetScale(self.window) catch 1); const size: f32 = @floatFromInt(asked_px / 2); const options: siv.ImageLoadDesc.Options = .{ .data = if (source == .data) source.data else &.{}, .path = if (source == .path) source.path else "", .max_width = size, .max_height = size, .scale = scale, }; const app = siv.windowGetApp(self.window); f.request = siv.imageLoad(app, options, onDecoded, onDone, load) catch |err| { log.warn("decoding a photo: {t}", .{err}); self.gpa.destroy(load); return; }; } fn onDecoded(user_data: ?*anyopaque, result: siv.Result, decoded: siv.Image) callconv(.c) void { const load: *Load = @ptrCast(@alignCast(user_data)); const self = load.avatars; const f = self.faces.getPtr(load.hash) orelse return; f.request = .null; if (result != .ok) return log.warn("decoding a photo: {t}", .{result}); if (f.image != .null) siv.imageDestroy(f.image); f.image = decoded; self.changed.call(self.changed.context); } fn onDone(user_data: ?*anyopaque) callconv(.c) void { const load: *Load = @ptrCast(@alignCast(user_data)); load.avatars.gpa.destroy(load); } test "Gravatar names an address by the SHA-256 of it trimmed and in lowercase" { // Gravatar's own example, from docs.gravatar.com/general/hash. const want = "84059b07d4be67b806386c0aad8070a23f18836bbaae342275dc0a83414c32ee"; try testing.expectEqualStrings(want, &hash("MyEmailAddress@example.com ")); try testing.expectEqualStrings(want, &hash("myemailaddress@example.com")); }