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 writers: Set /** The time to wait for changes to be written to disk. This delay is used to prevent file writes for each small update to the caps. */ private let saveDelay: TimeInterval = 1 /** The time when a save should occur. No save is necessary if this property is `nil`. */ private var nextSaveTime: Date? private var caps = [Int: Cap]() { didSet { scheduleSave() } } 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 scheduleSave() { nextSaveTime = Date().addingTimeInterval(saveDelay) DispatchQueue.global().asyncAfter(deadline: .now() + saveDelay) { self.performScheduledSave() } } private func performScheduledSave() { guard let date = nextSaveTime else { // No save necessary, or already saved return } guard date < Date() else { // Save pushed to future return } do { try saveCaps() nextSaveTime = nil } catch { // Attempt save again scheduleSave() } } 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 { caps = try caps.mapValues { var cap = $0 cap.count = try count(of: $0.id) return cap } } 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. - 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 { guard caps[cap] != nil else { throw CapError.unknownId } var id = 0 let capFolder = folder(of: cap) var f = file(of: cap, version: id) if fm.fileExists(atPath: capFolder.path) { while fm.fileExists(atPath: f.path) { id += 1 f = file(of: cap, version: id) } } else { try fm.createDirectory(at: capFolder, withIntermediateDirectories: true) } try data.write(to: f) caps[cap]!.count = try count(of: cap) log("Added image \(id) for cap \(cap)") } 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)") } func addOrUpdate(_ cap: Cap) throws { if let existingCap = caps[cap.id] { update(existingCap, with: cap) } else { try add(cap) } } private func add(_ cap: Cap) throws { guard cap.mainImage == 0 else { throw CapError.invalidData } var cap = cap cap.count = 0 caps[cap.id] = cap log("Added cap \(cap.id) '\(cap.name)'") } private func update(_ existingCap: Cap, with cap: Cap) { var updatedCap = existingCap if cap.name != "" { updatedCap.name = cap.name } let url = file(of: existingCap.id, version: cap.mainImage) if fm.fileExists(atPath: url.path) { updatedCap.mainImage = cap.mainImage } if let color = cap.color { updatedCap.color = color } caps[existingCap.id] = updatedCap log("Updated cap \(existingCap.id)") } }