Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(viewer): Address code review findings across all severity levels

P1: Fix actor reentrancy bug in ThumbnailService concurrency limiter
using slot-transfer pattern. Hoist ISO8601DateFormatter to static.

P2: Add UUID validation on /api/assets/:uuid, fix DOM XSS in lightbox
by replacing innerHTML with DOM APIs, clamp pageSize to 200, add O(1)
UUID dictionary index, eliminate double S3 round-trip (HEAD+GET to GET),
consolidate duplicate response types, remove dead yearExtractor code.

P3: Make VideoThumbnailer URL overload internal, add security headers
on HTML response, simplify AssetPage return type.

+117 -140
+15 -7
Sources/AtticCLI/Resources/viewer.html
··· 380 380 const res = await fetch(`/api/assets/${asset.uuid}`); 381 381 const detail = await res.json(); 382 382 383 + content.innerHTML = ''; 383 384 if (asset.isVideo) { 384 - content.innerHTML = ` 385 - <video controls preload="metadata" src="${detail.imageURL}" 386 - style="max-width:95vw;max-height:90vh"></video> 387 - <div class="lightbox-info">${asset.filename}</div>`; 385 + const video = document.createElement('video'); 386 + video.controls = true; 387 + video.preload = 'metadata'; 388 + video.src = detail.imageURL; 389 + video.style.cssText = 'max-width:95vw;max-height:90vh'; 390 + content.appendChild(video); 388 391 } else { 389 - content.innerHTML = ` 390 - <img src="${detail.imageURL}" alt="${asset.filename}"> 391 - <div class="lightbox-info">${asset.filename}</div>`; 392 + const img = document.createElement('img'); 393 + img.src = detail.imageURL; 394 + img.alt = asset.filename; 395 + content.appendChild(img); 392 396 } 397 + const info = document.createElement('div'); 398 + info.className = 'lightbox-info'; 399 + info.textContent = asset.filename; 400 + content.appendChild(info); 393 401 } 394 402 395 403 document.getElementById('lbClose').addEventListener('click', closeLightbox);
+44 -76
Sources/AtticCLI/ViewerServer.swift
··· 4 4 5 5 /// API response for a paginated asset list. 6 6 struct AssetListResponse: ResponseEncodable { 7 - var assets: [AssetWithURL] 7 + var assets: [AssetResponse] 8 8 var totalCount: Int 9 9 var page: Int 10 10 var pageSize: Int 11 11 } 12 12 13 - /// Single asset with a pre-signed image URL. 14 - struct AssetWithURL: Codable { 15 - var uuid: String 16 - var filename: String 17 - var dateCreated: String? 18 - var year: Int? 19 - var albums: [String] 20 - var isFavorite: Bool 21 - var isVideo: Bool 22 - var width: Int 23 - var height: Int 24 - var imageURL: String 25 - } 26 - 27 - /// API response for a single asset detail. 28 - struct AssetDetailResponse: ResponseEncodable { 13 + /// Single asset with a pre-signed image URL (used for both list and detail endpoints). 14 + struct AssetResponse: Codable, ResponseEncodable { 29 15 var uuid: String 30 16 var filename: String 31 17 var dateCreated: String? ··· 38 24 var imageURL: String 39 25 } 40 26 41 - /// API response for available filter values. 42 - struct FilterOptionsResponse: ResponseEncodable { 43 - var years: [YearCount] 44 - var albums: [AlbumCount] 45 - var totalAssets: Int 46 - var totalPhotos: Int 47 - var totalVideos: Int 48 - } 49 - 50 27 /// Localhost HTTP server for the photo viewer. 51 28 struct ViewerServer { 52 29 let dataStore: ViewerDataStore 53 30 let s3: S3Providing 54 - let thumbnailProvider: ThumbnailProviding? 31 + let thumbnailProvider: ThumbnailProviding 55 32 let port: Int 56 33 57 34 init( 58 35 dataStore: ViewerDataStore, 59 36 s3: S3Providing, 60 - thumbnailProvider: ThumbnailProviding? = nil, 37 + thumbnailProvider: ThumbnailProviding, 61 38 port: Int = 0 62 39 ) { 63 40 self.dataStore = dataStore ··· 86 63 let html = loadViewerHTML() 87 64 return Response( 88 65 status: .ok, 89 - headers: [.contentType: "text/html; charset=utf-8"], 66 + headers: [ 67 + .contentType: "text/html; charset=utf-8", 68 + .init("X-Content-Type-Options")!: "nosniff", 69 + .init("X-Frame-Options")!: "DENY", 70 + ], 90 71 body: .init(byteBuffer: .init(string: html)) 91 72 ) 92 73 } 93 74 94 - router.get("/api/filters") { _, _ -> FilterOptionsResponse in 75 + router.get("/api/filters") { _, _ -> Response in 95 76 let opts = await dataStore.filterOptions() 96 - return FilterOptionsResponse( 97 - years: opts.years, 98 - albums: opts.albums, 99 - totalAssets: opts.totalAssets, 100 - totalPhotos: opts.totalPhotos, 101 - totalVideos: opts.totalVideos 77 + let data = try JSONEncoder().encode(opts) 78 + return Response( 79 + status: .ok, 80 + headers: [.contentType: "application/json"], 81 + body: .init(byteBuffer: .init(data: data)) 102 82 ) 103 83 } 104 84 105 85 router.get("/api/assets") { request, _ -> AssetListResponse in 106 86 let params = request.uri.queryParameters 107 - let page = params.get("page", as: Int.self) ?? 1 108 - let pageSize = params.get("pageSize", as: Int.self) ?? 50 87 + let page = max(params.get("page", as: Int.self) ?? 1, 1) 88 + let pageSize = min(max(params.get("pageSize", as: Int.self) ?? 50, 1), 200) 109 89 let year = params.get("year", as: Int.self) 110 90 let album = params.get("album", as: String.self) 111 91 let favorites = params.get("favorites", as: Bool.self) ··· 117 97 ) 118 98 119 99 let assetsWithURLs = result.assets.map { asset in 120 - assetWithURL(asset, expires: 14400) 100 + assetResponse(asset, expires: 14400) 121 101 } 122 102 123 103 return AssetListResponse( 124 104 assets: assetsWithURLs, 125 105 totalCount: result.totalCount, 126 - page: result.page, 127 - pageSize: result.pageSize 106 + page: page, 107 + pageSize: pageSize 128 108 ) 129 109 } 130 110 131 111 router.get("/api/assets/:uuid") { _, context -> Response in 132 112 let uuid = try context.parameters.require("uuid") 113 + guard S3Paths.isValidUUID(uuid) else { 114 + return Response(status: .badRequest) 115 + } 133 116 guard let asset = await dataStore.asset(uuid: uuid) else { 134 117 return Response(status: .notFound) 135 118 } 136 119 137 - let detail = AssetDetailResponse( 138 - uuid: asset.uuid, 139 - filename: asset.filename, 140 - dateCreated: asset.dateCreated, 141 - year: asset.year, 142 - albums: asset.albums, 143 - isFavorite: asset.isFavorite, 144 - isVideo: asset.isVideo, 145 - width: asset.width, 146 - height: asset.height, 147 - imageURL: s3.presignedURL(key: asset.s3Key, expires: 14400) 148 - .absoluteString 149 - ) 150 - 120 + let detail = assetResponse(asset, expires: 14400) 151 121 let data = try JSONEncoder().encode(detail) 152 122 return Response( 153 123 status: .ok, ··· 156 126 ) 157 127 } 158 128 159 - if let thumbProvider = thumbnailProvider { 160 - router.get("/api/thumb/:uuid") { _, context -> Response in 161 - let uuid = try context.parameters.require("uuid") 162 - guard S3Paths.isValidUUID(uuid) else { 163 - return Response(status: .badRequest) 164 - } 165 - do { 166 - let data = try await thumbProvider.thumbnail(uuid: uuid) 167 - return Response( 168 - status: .ok, 169 - headers: [ 170 - .contentType: "image/jpeg", 171 - .cacheControl: "public, max-age=31536000, immutable", 172 - ], 173 - body: .init(byteBuffer: .init(data: data)) 174 - ) 175 - } catch { 176 - return Response(status: .notFound) 177 - } 129 + router.get("/api/thumb/:uuid") { _, context -> Response in 130 + let uuid = try context.parameters.require("uuid") 131 + guard S3Paths.isValidUUID(uuid) else { 132 + return Response(status: .badRequest) 133 + } 134 + do { 135 + let data = try await thumbnailProvider.thumbnail(uuid: uuid) 136 + return Response( 137 + status: .ok, 138 + headers: [ 139 + .contentType: "image/jpeg", 140 + .cacheControl: "public, max-age=31536000, immutable", 141 + ], 142 + body: .init(byteBuffer: .init(data: data)) 143 + ) 144 + } catch { 145 + return Response(status: .notFound) 178 146 } 179 147 } 180 148 181 149 return router 182 150 } 183 151 184 - private func assetWithURL(_ asset: AssetView, expires: Int) -> AssetWithURL { 185 - AssetWithURL( 152 + private func assetResponse(_ asset: AssetView, expires: Int) -> AssetResponse { 153 + AssetResponse( 186 154 uuid: asset.uuid, 187 155 filename: asset.filename, 188 156 dateCreated: asset.dateCreated,
+44 -33
Sources/AtticCore/ThumbnailService.swift
··· 41 41 42 42 // 3. Start a generation task (S3 thumbnail → generate from original) 43 43 let task = Task<Data, any Error> { [cache, s3] in 44 - // 3a. Check S3 for existing thumbnail 44 + // 3a. Check S3 for existing thumbnail (single GET, no HEAD) 45 45 let thumbKey = try S3Paths.thumbnailKey(uuid: uuid) 46 - if let meta = try? await s3.headObject(key: thumbKey), meta != nil { 47 - let data = try await s3.getObject(key: thumbKey) 46 + if let data = try? await s3.getObject(key: thumbKey) { 48 47 try? cache.put(uuid: uuid, data: data) 49 48 return data 50 49 } ··· 55 54 throw ThumbnailError.notFound(uuid) 56 55 } 57 56 58 - // Wait for a concurrency slot 59 - await self.acquireSlot() 60 - defer { Task { await self.releaseSlot() } } 61 - 62 - let originalData: Data 63 - do { 64 - originalData = try await s3.getObject(key: asset.s3Key) 65 - } catch { 66 - throw ThumbnailError.s3Failure(uuid, error) 67 - } 68 - 69 - let jpegData: Data 70 - if asset.isVideo { 71 - jpegData = try VideoThumbnailer.thumbnail(from: originalData) 72 - } else { 73 - jpegData = try ImageThumbnailer.thumbnail(from: originalData) 74 - } 75 - 76 - // Save to local cache 77 - try? cache.put(uuid: uuid, data: jpegData) 78 - 79 - // Best-effort upload to S3 (don't fail if upload errors) 80 - try? await s3.putObject( 81 - key: thumbKey, body: jpegData, contentType: "image/jpeg" 57 + return try await self.generateThumbnail( 58 + for: asset, uuid: uuid, thumbKey: thumbKey 82 59 ) 83 - 84 - return jpegData 85 60 } 86 61 87 62 inFlight[uuid] = task ··· 96 71 } 97 72 } 98 73 99 - // MARK: - Concurrency limiting 74 + // MARK: - Bounded generation 75 + 76 + /// Download original, generate thumbnail, save to cache + S3. 77 + /// Acquires and releases a concurrency slot synchronously within actor context. 78 + private func generateThumbnail( 79 + for asset: AssetView, uuid: String, thumbKey: String 80 + ) async throws -> Data { 81 + await acquireSlot() 82 + defer { releaseSlot() } 83 + 84 + let originalData: Data 85 + do { 86 + originalData = try await s3.getObject(key: asset.s3Key) 87 + } catch { 88 + throw ThumbnailError.s3Failure(uuid, error) 89 + } 90 + 91 + let jpegData: Data 92 + if asset.isVideo { 93 + jpegData = try VideoThumbnailer.thumbnail(from: originalData) 94 + } else { 95 + jpegData = try ImageThumbnailer.thumbnail(from: originalData) 96 + } 97 + 98 + // Save to local cache 99 + try? cache.put(uuid: uuid, data: jpegData) 100 + 101 + // Best-effort upload to S3 (don't fail if upload errors) 102 + try? await s3.putObject( 103 + key: thumbKey, body: jpegData, contentType: "image/jpeg" 104 + ) 105 + 106 + return jpegData 107 + } 108 + 109 + // MARK: - Concurrency limiting (slot-transfer pattern) 100 110 101 111 private func acquireSlot() async { 102 112 if activeGenerations < maxConcurrent { ··· 106 116 await withCheckedContinuation { continuation in 107 117 waiters.append(continuation) 108 118 } 109 - activeGenerations += 1 119 + // Slot was transferred by releaseSlot — no increment needed 110 120 } 111 121 112 122 private func releaseSlot() { 113 - activeGenerations -= 1 114 123 if !waiters.isEmpty { 115 124 let next = waiters.removeFirst() 116 - next.resume() 125 + next.resume() // Transfer the slot directly 126 + } else { 127 + activeGenerations -= 1 117 128 } 118 129 } 119 130 }
+1 -6
Sources/AtticCore/VideoThumbnailer.swift
··· 9 9 /// requires a file URL. 10 10 public enum VideoThumbnailer { 11 11 /// Extract a poster frame from a video file and return as JPEG thumbnail. 12 - /// - Parameters: 13 - /// - fileURL: URL to the video file on disk 14 - /// - maxDimension: Maximum width or height in pixels (default 400) 15 - /// - quality: JPEG compression quality 0.0-1.0 (default 0.8) 16 - /// - Returns: JPEG data 17 - public static func thumbnail( 12 + static func thumbnail( 18 13 from fileURL: URL, 19 14 maxDimension: Int = 400, 20 15 quality: Double = 0.8
+13 -17
Sources/AtticCore/ViewerDataStore.swift
··· 52 52 public var count: Int 53 53 } 54 54 55 - /// Paginated result from a filtered query. 55 + /// Result from a filtered query. 56 56 public struct AssetPage: Sendable { 57 57 public var assets: [AssetView] 58 58 public var totalCount: Int 59 - public var page: Int 60 - public var pageSize: Int 61 59 } 62 60 63 61 /// Loads all backed-up asset metadata from S3 into memory for fast filtering. 64 62 public actor ViewerDataStore { 65 63 private var assets: [AssetView] = [] 64 + private var assetsByUUID: [String: AssetView] = [:] 66 65 private var cachedFilterOptions: FilterOptions? 67 66 68 67 public init() {} ··· 122 121 } 123 122 } 124 123 125 - cachedFilterOptions = buildFilterOptions() 124 + rebuildIndexes() 126 125 } 127 126 128 127 /// Load from pre-built asset views (for testing). 129 128 public func load(assets: [AssetView]) { 130 129 self.assets = assets 130 + rebuildIndexes() 131 + } 132 + 133 + private func rebuildIndexes() { 134 + assetsByUUID = Dictionary(uniqueKeysWithValues: assets.map { ($0.uuid, $0) }) 131 135 cachedFilterOptions = buildFilterOptions() 132 136 } 133 137 ··· 168 172 169 173 return AssetPage( 170 174 assets: pageAssets, 171 - totalCount: totalCount, 172 - page: page, 173 - pageSize: pageSize 175 + totalCount: totalCount 174 176 ) 175 177 } 176 178 ··· 182 184 ) 183 185 } 184 186 185 - /// Find a single asset by UUID. 187 + /// Find a single asset by UUID (O(1) dictionary lookup). 186 188 public func asset(uuid: String) -> AssetView? { 187 - assets.first { $0.uuid == uuid } 189 + assetsByUUID[uuid] 188 190 } 189 191 190 192 // MARK: - Private ··· 221 223 "com.apple.m4v-video", "public.avi", 222 224 ] 223 225 224 - private static let yearExtractor: DateFormatter = { 225 - let df = DateFormatter() 226 - df.dateFormat = "yyyy" 227 - df.timeZone = TimeZone(identifier: "UTC") 228 - return df 229 - }() 226 + private nonisolated(unsafe) static let isoFormatter = ISO8601DateFormatter() 230 227 231 228 private static func assetView(from meta: AssetMetadata) -> AssetView { 232 229 let year: Int? 233 230 if let dateStr = meta.dateCreated, 234 - let date = ISO8601DateFormatter().date(from: dateStr) 235 - { 231 + let date = isoFormatter.date(from: dateStr) { 236 232 year = Calendar.current.component(.year, from: date) 237 233 } else { 238 234 year = nil
-1
Tests/AtticCoreTests/ViewerDataStoreTests.swift
··· 37 37 let result = await store.query() 38 38 #expect(result.totalCount == 5) 39 39 #expect(result.assets.count == 5) 40 - #expect(result.page == 1) 41 40 } 42 41 43 42 @Test func queryFiltersByYear() async {