import Foundation import Vapor final class CapServer { // MARK: Paths private let imageFolder: URL /// The file where the database of caps is stored private let dbFile: URL private let fm = FileManager.default // MARK: Caps private var saveImmediatelly = true private var writers: Set private var caps = [Int: Cap]() { didSet { guard saveImmediatelly else { return } try? saveCaps() } } var nextClassifierVersion: Int { caps.values.compactMap { $0.classifierVersion }.max() ?? 1 } init(in folder: URL, writers: [String]) throws { self.imageFolder = folder.appendingPathComponent("images") self.dbFile = folder.appendingPathComponent("caps.json") self.writers = Set(writers) var isDirectory: ObjCBool = false guard fm.fileExists(atPath: folder.path, isDirectory: &isDirectory), isDirectory.boolValue else { log("Public directory \(folder.path) is not a folder, or doesn't exist") throw CapError.invalidConfiguration } try loadCaps() try updateCounts() } private func loadCaps() throws { let data = try Data(contentsOf: dbFile) caps = try JSONDecoder().decode([Cap].self, from: data) .reduce(into: [:]) { $0[$1.id] = $1 } log("\(caps.count) caps loaded") } private func saveCaps() throws { let data = try JSONEncoder().encode(caps.values.sorted()) try data.write(to: dbFile) } // MARK: Paths func folder(of cap: Int) -> URL { imageFolder.appendingPathComponent(String(format: "%04d", cap)) } func file(of cap: Int, version: Int) -> URL { folder(of: cap).appendingPathComponent(String(format: "%04d-%02d.jpg", cap, version)) } // MARK: Authentication func hasAuthorization(for key: String) -> Bool { // Note: This is not a constant-time compare, so there may be an opportunity // for timing attack here. Sets perform hashed lookups, so this may be less of an issue, // and we're not doing anything critical in this application. // Worst case, an unauthorized person with a lot of free time and energy to hack this system // is able to change contents of the database, which are backed up in any case. writers.contains(key) } // MARK: Counts private func updateCounts() throws { saveImmediatelly = false caps = try caps.mapValues { var cap = $0 cap.count = try count(of: $0.id) return cap } saveImmediatelly = true try? saveCaps() } func countImages(in folder: URL) throws -> Int { try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) .filter({ $0.pathExtension == "jpg" }).count } /** Get the image count of a cap. */ func count(of cap: Int) throws -> Int { let f = folder(of: cap) guard fm.fileExists(atPath: f.path) else { return 0 } return try countImages(in: f) } // MARK: Names func set(name: String, for capId: Int) throws { guard var cap = caps[capId] else { let url = server.folder(of: capId) if !fm.fileExists(atPath: url.path) { try fm.createDirectory(at: url, withIntermediateDirectories: false) } caps[capId] = Cap(id: capId, name: name, count: 0, mainImage: 0, classifierVersion: nextClassifierVersion) log("Added cap \(capId)") return } cap.name = name caps[capId] = cap log("Set name for cap \(capId)") } // MARK: Images /** Save a cap image to disk. Automatically creates the image name with the current image count. - Parameter data: The image data - Parameter cap: The id of the cap. - Returns: The new image count for the cap - Throws: `CapError.unknownId`, if the cap doesn't exist. `CapError.dataInconsistency` if an image already exists for the current count. */ func save(image data: Data, for cap: Int) throws -> Int { guard var count = caps[cap]?.count else { throw CapError.unknownId } var id = 0 var f = file(of: cap, version: id) while fm.fileExists(atPath: f.path) { id += 1 f = file(of: cap, version: id) } try data.write(to: f) count += 1 caps[cap]!.count = count log("Added image \(id) for cap \(cap)") return count } func switchMainImage(to version: Int, for cap: Int) throws { let file2 = file(of: cap, version: version) guard fm.fileExists(atPath: file2.path) else { log("No image \(version) for cap \(cap)") throw CapError.invalidFile } caps[cap]?.mainImage = version log("Switched cap \(cap) to version \(version)") } }