Compare commits
39 Commits
f956b8b4d2
...
master
Author | SHA1 | Date | |
---|---|---|---|
5cf3c8d0c1 | |||
7b3210169a | |||
e34845ab24 | |||
400753a9a2 | |||
1c57b538be | |||
5138bb543e | |||
e6132a38b3 | |||
6aaa9cb458 | |||
1fd63b8cc3 | |||
3fa699e9bf | |||
14f06072ad | |||
848ff21134 | |||
cc7a3ec567 | |||
ec21c06581 | |||
c7327c8571 | |||
29a72032c6 | |||
e7aa2774df | |||
7152346a86 | |||
76a68f9d03 | |||
3ed5f0e0ff | |||
289e927c6a | |||
e7a54ec8ec | |||
b094762297 | |||
6e5cc06d31 | |||
e767957e64 | |||
eb6e5d5258 | |||
d596a9d790 | |||
464f87fdb5 | |||
fe0fdec03d | |||
8a42dddb2c | |||
cf3d5d0169 | |||
623a226816 | |||
9fcf1756ef | |||
e4f2fc547b | |||
29ecde067f | |||
600464b889 | |||
c5ce5414a9 | |||
f3ee7a4fb4 | |||
026336f24d |
13
.gitignore
vendored
@ -8,12 +8,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Package.resolved
|
Package.resolved
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
Public/caps.json
|
||||||
|
Public/changes.txt
|
||||||
|
Public/classifier.*
|
||||||
|
Public/count.*
|
||||||
Public/images/
|
Public/images/
|
||||||
Public/thumbnails/
|
Public/thumbnails/
|
||||||
Public/classifier.version
|
Resources/config.json
|
||||||
Public/classifier.mlmodel
|
Resources/logs/
|
||||||
Public/caps.json
|
|
||||||
Training/backup/
|
|
||||||
Training/config.json
|
|
||||||
Public/thumbnails
|
|
||||||
Public/count.js
|
|
||||||
|
@ -8,22 +8,20 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/vapor/vapor", from: "4.0.0"),
|
.package(url: "https://github.com/vapor/vapor", from: "4.0.0"),
|
||||||
.package(url: "https://github.com/christophhagen/Clairvoyant", from: "0.2.0"),
|
.package(url: "https://github.com/christophhagen/Clairvoyant", from: "0.11.2"),
|
||||||
|
.package(url: "https://github.com/christophhagen/ClairvoyantVapor", from: "0.4.0"),
|
||||||
|
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "App",
|
.executableTarget(
|
||||||
dependencies: [
|
name: "App",
|
||||||
.product(name: "Vapor", package: "vapor"),
|
dependencies: [
|
||||||
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
.product(name: "Vapor", package: "vapor"),
|
||||||
],
|
.product(name: "Clairvoyant", package: "Clairvoyant"),
|
||||||
swiftSettings: [
|
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
|
||||||
// Enable better optimizations when building in Release configuration. Despite the use of
|
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
|
||||||
// the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release
|
]
|
||||||
// builds. See <https://github.com/swift-server/guides#building-for-production> for details.
|
)
|
||||||
.unsafeFlags(["-cross-module-optimization"], .when(configuration: .release))
|
|
||||||
]),
|
|
||||||
.executableTarget(name: "Run", dependencies: ["App"]),
|
|
||||||
.testTarget(name: "AppTests", dependencies: ["App"]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BIN
Public/assets/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
Public/assets/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 171 KiB |
BIN
Public/assets/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 30 KiB |
9
Public/assets/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/assets/mstile-150x150.png?v=1"/>
|
||||||
|
<TileColor>#a36490</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
BIN
Public/assets/favicon-16x16.png
Normal file
After Width: | Height: | Size: 953 B |
BIN
Public/assets/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
Public/assets/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
Public/assets/mstile-150x150.png
Normal file
After Width: | Height: | Size: 30 KiB |
1
Public/assets/site.webmanifest
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
@ -5,13 +5,13 @@
|
|||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="robots" content="noindex"/>
|
<meta name="robots" content="noindex"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png?v=1"/>
|
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png?v=1"/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icons/favicon-32x32.png?v=1"/>
|
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png?v=1"/>
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png?v=1"/>
|
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png?v=1"/>
|
||||||
<link rel="manifest" href="/assets/icons/site.webmanifest?v=1"/>
|
<link rel="manifest" href="/assets/site.webmanifest?v=1"/>
|
||||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico?v=1"/>
|
<link rel="shortcut icon" href="/assets/favicon.ico?v=1"/>
|
||||||
<meta name="msapplication-TileColor" content="#da532c">
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
<meta name="msapplication-config" content="/assets/icons/browserconfig.xml?v=1"/>
|
<meta name="msapplication-config" content="/assets/browserconfig.xml?v=1"/>
|
||||||
<meta name="theme-color" content="#ffffff"/>
|
<meta name="theme-color" content="#ffffff"/>
|
||||||
<link href="grid.css?v=2" rel="stylesheet"/>
|
<link href="grid.css?v=2" rel="stylesheet"/>
|
||||||
<meta name="author" content="Christoph Hagen"/>
|
<meta name="author" content="Christoph Hagen"/>
|
||||||
|
26
Readme.md
@ -51,31 +51,7 @@ Note: The data for the mosaic is currently not updated automatically, since Swif
|
|||||||
## Classifier training
|
## Classifier training
|
||||||
|
|
||||||
The main server is running on Linux, which doesn't provide the CreateML framework required for classifier training.
|
The main server is running on Linux, which doesn't provide the CreateML framework required for classifier training.
|
||||||
This has to be done on macOS using the `train.swift` script in the `Training` folder.
|
This has to be done on macOS using the [Caps-Train](https://christophhagen.de/git/ch/Caps-Train) repository.
|
||||||
It will:
|
|
||||||
- Fetch the current image catalog by checking missing and changed images
|
|
||||||
- Train a classifier on the image set
|
|
||||||
- Increment the classifier version and upload the new model
|
|
||||||
- Update the list of caps which can be recognized using the classifier
|
|
||||||
- Create missing thumbnails for each cap for the cap grid
|
|
||||||
|
|
||||||
A configuration file is required to run the training, with a valid access token for the server:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"imageDirectory": "../Public/images",
|
|
||||||
"classifierModelPath": "../Public/classifier.mlmodel",
|
|
||||||
"trainingIterations": 20,
|
|
||||||
"serverPath": "https://mydomain.com/caps",
|
|
||||||
"authenticationToken": "mysecretkey",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The configuration file `config.json` must be located in the folder from which the script is run.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
swift train.swift
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
- Create thumbnails on the server using [JPEG](https://github.com/kelvin13/jpeg)
|
- Create thumbnails on the server using [JPEG](https://github.com/kelvin13/jpeg)
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"port" : 6001,
|
"port" : 6001,
|
||||||
"maxBodySize" : "2mb",
|
"maxBodySize" : "2mb",
|
||||||
"logPath": "\/var\/log\/caps.log",
|
"logPath": "\/var\/log\/caps/metrics",
|
||||||
"serveFiles": true,
|
"serveFiles": true,
|
||||||
|
"dataDirectory" : "/ch/data/caps",
|
||||||
"writers" : [
|
"writers" : [
|
||||||
"auth_key_1"
|
"auth_key_1"
|
||||||
]
|
]
|
||||||
|
29
Sources/App/Authenticator.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
final class Authenticator {
|
||||||
|
|
||||||
|
private var writers: Set<String>
|
||||||
|
|
||||||
|
init(writers: [String]) {
|
||||||
|
self.writers = Set(writers)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorize(_ request: Request) throws {
|
||||||
|
guard let key = request.headers.first(name: "key") else {
|
||||||
|
throw Abort(.badRequest) // 400
|
||||||
|
}
|
||||||
|
guard hasAuthorization(for: key) else {
|
||||||
|
throw Abort(.forbidden) // 403
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,9 +10,6 @@ struct Cap: Codable {
|
|||||||
|
|
||||||
var mainImage: Int
|
var mainImage: Int
|
||||||
|
|
||||||
/// The version of the first classifier trained on this cap
|
|
||||||
var classifierVersion: Int?
|
|
||||||
|
|
||||||
var color: Color?
|
var color: Color?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@ -20,7 +17,6 @@ struct Cap: Codable {
|
|||||||
case name = "n"
|
case name = "n"
|
||||||
case count = "c"
|
case count = "c"
|
||||||
case mainImage = "m"
|
case mainImage = "m"
|
||||||
case classifierVersion = "v"
|
|
||||||
case color = "f"
|
case color = "f"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
157
Sources/App/CapServer+Routes.swift
Executable file
@ -0,0 +1,157 @@
|
|||||||
|
import Vapor
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The decoder to extract caps from JSON payloads given to the `cap` route.
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
/// The date formatter to decode dates in requests
|
||||||
|
private let dateFormatter: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateFormat = "yy-MM-dd-HH-mm-ss"
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
|
||||||
|
extension CapServer {
|
||||||
|
|
||||||
|
private func ensureOperability() throws {
|
||||||
|
guard isOperational else {
|
||||||
|
throw Abort(.noContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerRoutes(with app: Application, authenticator: Authenticator) {
|
||||||
|
app.get("version") { _ in
|
||||||
|
try self.ensureOperability()
|
||||||
|
return "\(self.classifierVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or change a cap
|
||||||
|
app.postCatching("cap") { request in
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
let data = try request.getBodyData(request: "/cap")
|
||||||
|
let cap = try decoder.decode(Cap.self, from: data)
|
||||||
|
try self.addOrUpdate(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload an image
|
||||||
|
app.postCatching("image", .parameter("cap")) { request in
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
||||||
|
log("/images/:cap: Invalid 'cap' parameter")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
let data = try request.getBodyData(request: "/images/:cap")
|
||||||
|
try self.save(image: data, for: cap)
|
||||||
|
}
|
||||||
|
// Update the classifier
|
||||||
|
app.on(.POST, "classifier", ":version", body: .collect(maxSize: "50mb")) { request -> HTTPStatus in
|
||||||
|
guard let version = request.parameters.get("version", as: Int.self) else {
|
||||||
|
log("/classifier/:version: Invalid parameter for version")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
guard version > self.classifierVersion else {
|
||||||
|
throw Abort(.alreadyReported) // 208
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
let data = try request.getBodyData(request: "/classifier/:version")
|
||||||
|
try self.save(classifier: data, version: version)
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the trained classes
|
||||||
|
app.postCatching("classes", ":date") { request in
|
||||||
|
guard let dateString = request.parameters.get("date") else {
|
||||||
|
log("/classes/:date: Invalid 'date' parameter")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
guard let date = dateFormatter.date(from: dateString) else {
|
||||||
|
log("/classes/:date: Invalid 'date' specification")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
let body = try request.getStringBody(request: "/classes/:date")
|
||||||
|
|
||||||
|
try self.saveTrainedClasses(content: body)
|
||||||
|
self.removeAllEntriesInImageChangeList(before: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of missing thumbnails
|
||||||
|
app.get("thumbnails", "missing") { request in
|
||||||
|
try self.ensureOperability()
|
||||||
|
let missingThumbnails = self.getListOfMissingThumbnails()
|
||||||
|
return missingThumbnails.map(String.init).joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the thumbnail of a cap
|
||||||
|
app.postCatching("thumbnails", ":cap") { request in
|
||||||
|
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
||||||
|
log("/thumbnails/:cap: Invalid cap parameter for thumbnail upload")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
let data = try request.getBodyData(request: "/thumbnails/:cap")
|
||||||
|
self.saveThumbnail(data, for: cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the image of a cap
|
||||||
|
app.postCatching("delete", ":cap", ":version") { request in
|
||||||
|
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
||||||
|
log("/delete/:cap/:version: Invalid 'cap' parameter for image deletion")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
guard let version = request.parameters.get("version", as: Int.self) else {
|
||||||
|
log("/delete/:cap/:version: Invalid 'version' parameter for image deletion")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
guard let cap = self.deleteImage(version: version, for: cap) else {
|
||||||
|
throw Abort(.gone)
|
||||||
|
}
|
||||||
|
return try encoder.encode(cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a cap completely, with all images
|
||||||
|
app.postCatching("delete", ":cap") { request in
|
||||||
|
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
||||||
|
log("/delete/:cap/: Invalid 'cap' parameter for image deletion")
|
||||||
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
try self.ensureOperability()
|
||||||
|
try authenticator.authorize(request)
|
||||||
|
guard self.delete(cap: cap) else {
|
||||||
|
throw Abort(.gone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Request {
|
||||||
|
|
||||||
|
func getBodyData(request: String) throws -> Data {
|
||||||
|
guard let buffer = body.data else {
|
||||||
|
log("\(request): Missing body data")
|
||||||
|
throw CapError.invalidBody
|
||||||
|
}
|
||||||
|
return Data(buffer: buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringBody(request: String) throws -> String {
|
||||||
|
let data = try getBodyData(request: request)
|
||||||
|
guard let content = String(data: data, encoding: .utf8) else {
|
||||||
|
log("\(request): Invalid string body")
|
||||||
|
throw CapError.invalidBody
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,11 @@ import Foundation
|
|||||||
import Vapor
|
import Vapor
|
||||||
import Clairvoyant
|
import Clairvoyant
|
||||||
|
|
||||||
final class CapServer: ServerOwner {
|
final class CapServer {
|
||||||
|
|
||||||
|
private let imageSize = 360
|
||||||
|
|
||||||
|
private let thumbnailSize = 100
|
||||||
|
|
||||||
// MARK: Paths
|
// MARK: Paths
|
||||||
|
|
||||||
@ -23,15 +27,21 @@ final class CapServer: ServerOwner {
|
|||||||
|
|
||||||
private let classifierFile: URL
|
private let classifierFile: URL
|
||||||
|
|
||||||
|
private let classifierClassesFile: URL
|
||||||
|
|
||||||
private let changedImagesFile: URL
|
private let changedImagesFile: URL
|
||||||
|
|
||||||
private let fm = FileManager.default
|
private let fm = FileManager.default
|
||||||
|
|
||||||
private let changedImageEntryDateFormatter: DateFormatter
|
private let changedImageEntryDateFormatter: DateFormatter
|
||||||
|
|
||||||
|
/// Indicates that the data is loaded
|
||||||
|
private(set) var isOperational = false
|
||||||
|
|
||||||
|
private(set) var canResizeImages = false
|
||||||
|
|
||||||
// MARK: Caps
|
// MARK: Caps
|
||||||
|
|
||||||
private var writers: Set<String>
|
|
||||||
|
|
||||||
/// The changed images not yet written to disk
|
/// The changed images not yet written to disk
|
||||||
private var unwrittenImageChanges: [(cap: Int, image: Int)] = []
|
private var unwrittenImageChanges: [(cap: Int, image: Int)] = []
|
||||||
@ -39,7 +49,9 @@ final class CapServer: ServerOwner {
|
|||||||
var classifierVersion: Int = 0 {
|
var classifierVersion: Int = 0 {
|
||||||
didSet {
|
didSet {
|
||||||
writeClassifierVersion()
|
writeClassifierVersion()
|
||||||
updateMonitoredClassifierVersionProperty()
|
Task {
|
||||||
|
try? await classifierMetric.update(classifierVersion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,14 +72,13 @@ final class CapServer: ServerOwner {
|
|||||||
private var caps = [Int: Cap]() {
|
private var caps = [Int: Cap]() {
|
||||||
didSet {
|
didSet {
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
updateMonitoredPropertiesOnCapChange()
|
Task {
|
||||||
|
_ = try? await capCountMetric.update(caps.count)
|
||||||
|
_ = try? await imageCountMetric.update(imageCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextClassifierVersion: Int {
|
|
||||||
caps.values.compactMap { $0.classifierVersion }.max() ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var capCount: Int {
|
var capCount: Int {
|
||||||
caps.count
|
caps.count
|
||||||
}
|
}
|
||||||
@ -76,7 +87,7 @@ final class CapServer: ServerOwner {
|
|||||||
caps.reduce(0) { $0 + $1.value.count }
|
caps.reduce(0) { $0 + $1.value.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(in folder: URL, writers: [String]) {
|
init(in folder: URL) {
|
||||||
self.imageFolder = folder.appendingPathComponent("images")
|
self.imageFolder = folder.appendingPathComponent("images")
|
||||||
self.thumbnailFolder = folder.appendingPathComponent("thumbnails")
|
self.thumbnailFolder = folder.appendingPathComponent("thumbnails")
|
||||||
self.gridCountFile = folder.appendingPathComponent("count.js")
|
self.gridCountFile = folder.appendingPathComponent("count.js")
|
||||||
@ -84,10 +95,23 @@ final class CapServer: ServerOwner {
|
|||||||
self.htmlFile = folder.appendingPathComponent("count.html")
|
self.htmlFile = folder.appendingPathComponent("count.html")
|
||||||
self.classifierVersionFile = folder.appendingPathComponent("classifier.version")
|
self.classifierVersionFile = folder.appendingPathComponent("classifier.version")
|
||||||
self.classifierFile = folder.appendingPathComponent("classifier.mlmodel")
|
self.classifierFile = folder.appendingPathComponent("classifier.mlmodel")
|
||||||
|
self.classifierClassesFile = folder.appendingPathComponent("classifier.classes")
|
||||||
self.changedImagesFile = folder.appendingPathComponent("changes.txt")
|
self.changedImagesFile = folder.appendingPathComponent("changes.txt")
|
||||||
self.writers = Set(writers)
|
|
||||||
self.changedImageEntryDateFormatter = DateFormatter()
|
self.changedImageEntryDateFormatter = DateFormatter()
|
||||||
changedImageEntryDateFormatter.dateFormat = "yy-MM-dd-HH-mm-ss"
|
changedImageEntryDateFormatter.dateFormat = "yy-MM-dd-HH-mm-ss"
|
||||||
|
|
||||||
|
// Metric initializers only fail if observer is missing or ID is duplicate
|
||||||
|
self.capCountMetric = .init("caps.count",
|
||||||
|
name: "Number of caps",
|
||||||
|
description: "The total number of caps in the database")
|
||||||
|
|
||||||
|
self.imageCountMetric = .init("caps.images",
|
||||||
|
name: "Total images",
|
||||||
|
description: "The total number of images for all caps")
|
||||||
|
|
||||||
|
self.classifierMetric = .init("caps.classifier",
|
||||||
|
name: "Classifier Version",
|
||||||
|
description: "The current version of the image classifier")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadData() throws {
|
func loadData() throws {
|
||||||
@ -97,10 +121,17 @@ final class CapServer: ServerOwner {
|
|||||||
updateGridCapCount()
|
updateGridCapCount()
|
||||||
try ensureExistenceOfChangedImagesFile()
|
try ensureExistenceOfChangedImagesFile()
|
||||||
organizeImages()
|
organizeImages()
|
||||||
|
if let version = getMagickVersion() {
|
||||||
|
log("Using ImageMagick \(version.rawValue)")
|
||||||
|
canResizeImages = true
|
||||||
|
}
|
||||||
|
// shrinkImages()
|
||||||
|
createMissingThumbnails()
|
||||||
|
isOperational = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadClassifierVersion(at url: URL) {
|
private func loadClassifierVersion(at url: URL) {
|
||||||
guard fm.fileExists(atPath: url.path) else {
|
guard exists(url) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let content: String
|
let content: String
|
||||||
@ -129,6 +160,10 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadCaps() throws {
|
private func loadCaps() throws {
|
||||||
|
guard exists(dbFile) else {
|
||||||
|
log("No cap database found")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: dbFile)
|
let data = try Data(contentsOf: dbFile)
|
||||||
caps = try JSONDecoder().decode([Cap].self, from: data)
|
caps = try JSONDecoder().decode([Cap].self, from: data)
|
||||||
@ -137,7 +172,6 @@ final class CapServer: ServerOwner {
|
|||||||
log("Failed to load caps: \(error)")
|
log("Failed to load caps: \(error)")
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
log("\(caps.count) caps loaded")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleSave() {
|
private func scheduleSave() {
|
||||||
@ -174,7 +208,7 @@ final class CapServer: ServerOwner {
|
|||||||
let count = caps.count
|
let count = caps.count
|
||||||
let content =
|
let content =
|
||||||
"""
|
"""
|
||||||
<body style="margin: 0;">
|
<body style="margin: 0;overflow: hidden">
|
||||||
<div style="display: flex; justify-content: center;">
|
<div style="display: flex; justify-content: center;">
|
||||||
<div style="font-size: 60px; font-family: 'SF Pro Display',-apple-system,BlinkMacSystemFont,Helvetica,sans-serif; -webkit-font-smoothing: antialiased;">\(count)</div>
|
<div style="font-size: 60px; font-family: 'SF Pro Display',-apple-system,BlinkMacSystemFont,Helvetica,sans-serif; -webkit-font-smoothing: antialiased;">\(count)</div>
|
||||||
</div>
|
</div>
|
||||||
@ -187,18 +221,51 @@ final class CapServer: ServerOwner {
|
|||||||
caps.values.sorted().forEach(organizeImages)
|
caps.values.sorted().forEach(organizeImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func createImageFolder(for cap: Int) throws {
|
||||||
|
let folderUrl = folder(of: cap)
|
||||||
|
do {
|
||||||
|
try fm.createDirectory(at: folderUrl, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
log("Failed to create folder for cap \(cap): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Rearrange images of a cap to ensure that an image exists for each number from 0 to `image count - 1`.
|
||||||
|
This is done by using the last images to fill in possible gaps in the sequence.
|
||||||
|
E.g. If there are images `0`, `2`, `3`, then `3` will be renamed to `1`.
|
||||||
|
- Note: The main image is also changed, if the main image is renamed.
|
||||||
|
*/
|
||||||
private func organizeImages(for cap: Cap) {
|
private func organizeImages(for cap: Cap) {
|
||||||
var cap = cap
|
var cap = cap
|
||||||
guard let images = try? images(in: folder(of: cap.id)) else {
|
let folderUrl = folder(of: cap.id)
|
||||||
|
guard exists(folderUrl) else {
|
||||||
|
try? createImageFolder(for: cap.id)
|
||||||
|
cap.count = 0
|
||||||
|
caps[cap.id] = cap
|
||||||
|
log("Found cap \(cap.id) without image folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let images = try? images(in: folderUrl) else {
|
||||||
log("Failed to get image urls for cap \(cap.id)")
|
log("Failed to get image urls for cap \(cap.id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if images.count != cap.count {
|
||||||
|
log("\(images.count) instead of \(cap.count) images for cap \(cap.id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of existing images
|
||||||
var sorted: [(id: Int, url: URL)] = images.compactMap {
|
var sorted: [(id: Int, url: URL)] = images.compactMap {
|
||||||
guard let id = Int($0.deletingPathExtension().lastPathComponent.components(separatedBy: "-").last!) else {
|
guard let id = Int($0.deletingPathExtension().lastPathComponent.components(separatedBy: "-").last!) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (id, $0)
|
return (id, $0)
|
||||||
}.sorted { $0.id < $1.id }
|
}.sorted { $0.id < $1.id }
|
||||||
|
|
||||||
|
// Check that all images are available
|
||||||
for version in 0..<images.count {
|
for version in 0..<images.count {
|
||||||
guard version != sorted[version].id else {
|
guard version != sorted[version].id else {
|
||||||
continue
|
continue
|
||||||
@ -207,6 +274,7 @@ final class CapServer: ServerOwner {
|
|||||||
let newUrl = imageUrl(of: cap.id, version: version)
|
let newUrl = imageUrl(of: cap.id, version: version)
|
||||||
do {
|
do {
|
||||||
try fm.moveItem(at: lastImage.url, to: newUrl)
|
try fm.moveItem(at: lastImage.url, to: newUrl)
|
||||||
|
log("Moved image \(lastImage.id) to \(version) for cap \(cap.id)")
|
||||||
} catch {
|
} catch {
|
||||||
log("Failed to move file \(lastImage.url.path) to \(newUrl.path): \(error)")
|
log("Failed to move file \(lastImage.url.path) to \(newUrl.path): \(error)")
|
||||||
return
|
return
|
||||||
@ -216,7 +284,14 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
sorted.insert((version, newUrl), at: version)
|
sorted.insert((version, newUrl), at: version)
|
||||||
}
|
}
|
||||||
|
|
||||||
cap.count = sorted.count
|
cap.count = sorted.count
|
||||||
|
|
||||||
|
// Fix invalid main image
|
||||||
|
if cap.mainImage >= cap.count || cap.mainImage < 0 {
|
||||||
|
cap.mainImage = 0
|
||||||
|
}
|
||||||
|
|
||||||
caps[cap.id] = cap
|
caps[cap.id] = cap
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,33 +309,31 @@ final class CapServer: ServerOwner {
|
|||||||
folder(of: cap).appendingPathComponent(String(format: "%04d-%02d.jpg", cap, version))
|
folder(of: cap).appendingPathComponent(String(format: "%04d-%02d.jpg", cap, version))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Authentication
|
private func exists(_ url: URL) -> Bool {
|
||||||
|
fm.fileExists(atPath: url.path)
|
||||||
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
|
// MARK: Counts
|
||||||
|
|
||||||
private func images(in folder: URL) throws -> [URL] {
|
private func images(in folder: URL) throws -> [URL] {
|
||||||
try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
do {
|
||||||
.filter { $0.pathExtension == "jpg" }
|
return try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||||
|
.filter { $0.pathExtension == "jpg" }
|
||||||
|
} catch {
|
||||||
|
log("Failed to get image urls for cap \(folder.lastPathComponent): \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Get the image count of a cap.
|
Get the image count of a cap.
|
||||||
*/
|
*/
|
||||||
func count(of cap: Int) throws -> Int {
|
func count(of cap: Int) throws -> Int {
|
||||||
let f = folder(of: cap)
|
let capImageFolder = folder(of: cap)
|
||||||
guard fm.fileExists(atPath: f.path) else {
|
guard exists(capImageFolder) else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return try images(in: f).count
|
return try images(in: capImageFolder).count
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Images
|
// MARK: Images
|
||||||
@ -275,23 +348,34 @@ final class CapServer: ServerOwner {
|
|||||||
*/
|
*/
|
||||||
func save(image data: Data, for cap: Int) throws {
|
func save(image data: Data, for cap: Int) throws {
|
||||||
guard caps[cap] != nil else {
|
guard caps[cap] != nil else {
|
||||||
|
log("Tried to save image for unknown cap \(cap)")
|
||||||
throw CapError.unknownId
|
throw CapError.unknownId
|
||||||
}
|
}
|
||||||
var id = 0
|
var id = 0
|
||||||
let capFolder = folder(of: cap)
|
let capFolder = folder(of: cap)
|
||||||
var f = imageUrl(of: cap, version: id)
|
var capImageUrl = imageUrl(of: cap, version: id)
|
||||||
if fm.fileExists(atPath: capFolder.path) {
|
if exists(capFolder) {
|
||||||
while fm.fileExists(atPath: f.path) {
|
while exists(capImageUrl) {
|
||||||
id += 1
|
id += 1
|
||||||
f = imageUrl(of: cap, version: id)
|
capImageUrl = imageUrl(of: cap, version: id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try fm.createDirectory(at: capFolder, withIntermediateDirectories: true)
|
try createImageFolder(for: cap)
|
||||||
}
|
}
|
||||||
try data.write(to: f)
|
do {
|
||||||
caps[cap]!.count = try count(of: cap)
|
try data.write(to: capImageUrl)
|
||||||
|
} catch {
|
||||||
|
log("Failed to write image \(id) for cap \(cap): \(error)")
|
||||||
|
throw CapError.invalidFile
|
||||||
|
}
|
||||||
|
let count = try count(of: cap)
|
||||||
|
caps[cap]!.count = count
|
||||||
addChangedImageToLog(cap: cap, image: id)
|
addChangedImageToLog(cap: cap, image: id)
|
||||||
log("Added image \(id) for cap \(cap)")
|
if canResizeImages {
|
||||||
|
shrink(imageAt: capImageUrl, size: imageSize, destination: capImageUrl)
|
||||||
|
createThumbnail(for: cap)
|
||||||
|
}
|
||||||
|
log("Added image \(id) for cap \(cap) (\(count) total)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeChangedImagesToDisk() throws {
|
private func writeChangedImagesToDisk() throws {
|
||||||
@ -299,8 +383,17 @@ final class CapServer: ServerOwner {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let handle = try FileHandle(forWritingTo: changedImagesFile)
|
try ensureExistenceOfChangedImagesFile()
|
||||||
try handle.seekToEnd()
|
|
||||||
|
let handle: FileHandle
|
||||||
|
do {
|
||||||
|
handle = try FileHandle(forWritingTo: changedImagesFile)
|
||||||
|
try handle.seekToEnd()
|
||||||
|
} catch {
|
||||||
|
log("Failed to open changed images file for writing: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
var entries = unwrittenImageChanges
|
var entries = unwrittenImageChanges
|
||||||
defer {
|
defer {
|
||||||
unwrittenImageChanges = entries
|
unwrittenImageChanges = entries
|
||||||
@ -309,7 +402,12 @@ final class CapServer: ServerOwner {
|
|||||||
let dateString = changedImageEntryDateFormatter.string(from: Date())
|
let dateString = changedImageEntryDateFormatter.string(from: Date())
|
||||||
while let entry = entries.popLast() {
|
while let entry = entries.popLast() {
|
||||||
let content = "\(dateString):\(entry.cap):\(entry.image)\n".data(using: .utf8)!
|
let content = "\(dateString):\(entry.cap):\(entry.image)\n".data(using: .utf8)!
|
||||||
try handle.write(contentsOf: content)
|
do {
|
||||||
|
try handle.write(contentsOf: content)
|
||||||
|
} catch {
|
||||||
|
log("Failed to write entry to changed images file: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -323,7 +421,7 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ensureExistenceOfChangedImagesFile() throws {
|
private func ensureExistenceOfChangedImagesFile() throws {
|
||||||
guard !fm.fileExists(atPath: changedImagesFile.path) else {
|
if exists(changedImagesFile) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@ -335,6 +433,10 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeAllEntriesInImageChangeList(before date: Date) {
|
func removeAllEntriesInImageChangeList(before date: Date) {
|
||||||
|
guard exists(changedImagesFile) else {
|
||||||
|
log("No file for changed images to update")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try String(contentsOf: changedImagesFile)
|
try String(contentsOf: changedImagesFile)
|
||||||
.components(separatedBy: "\n")
|
.components(separatedBy: "\n")
|
||||||
@ -357,12 +459,15 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func switchMainImage(to version: Int, for cap: Int) throws {
|
func switchMainImage(to version: Int, for cap: Int) throws {
|
||||||
let file2 = imageUrl(of: cap, version: version)
|
let capImageUrl = imageUrl(of: cap, version: version)
|
||||||
guard fm.fileExists(atPath: file2.path) else {
|
guard exists(capImageUrl) else {
|
||||||
log("No image \(version) for cap \(cap)")
|
log("No image \(version) for cap \(cap)")
|
||||||
throw CapError.invalidFile
|
throw CapError.invalidFile
|
||||||
}
|
}
|
||||||
caps[cap]?.mainImage = version
|
caps[cap]?.mainImage = version
|
||||||
|
if canResizeImages {
|
||||||
|
createThumbnail(for: cap)
|
||||||
|
}
|
||||||
log("Switched cap \(cap) to version \(version)")
|
log("Switched cap \(cap) to version \(version)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,11 +481,11 @@ final class CapServer: ServerOwner {
|
|||||||
|
|
||||||
private func add(_ cap: Cap) throws {
|
private func add(_ cap: Cap) throws {
|
||||||
guard cap.mainImage == 0 else {
|
guard cap.mainImage == 0 else {
|
||||||
|
log("Attempting to add cap \(cap.id) with main image \(cap.mainImage)")
|
||||||
throw CapError.invalidData
|
throw CapError.invalidData
|
||||||
}
|
}
|
||||||
var cap = cap
|
var cap = cap
|
||||||
cap.count = 0
|
cap.count = 0
|
||||||
cap.classifierVersion = nextClassifierVersion
|
|
||||||
caps[cap.id] = cap
|
caps[cap.id] = cap
|
||||||
saveCapCountHTML()
|
saveCapCountHTML()
|
||||||
updateGridCapCount()
|
updateGridCapCount()
|
||||||
@ -392,8 +497,8 @@ final class CapServer: ServerOwner {
|
|||||||
if cap.name != "" {
|
if cap.name != "" {
|
||||||
updatedCap.name = cap.name
|
updatedCap.name = cap.name
|
||||||
}
|
}
|
||||||
let url = imageUrl(of: existingCap.id, version: cap.mainImage)
|
let capImageUrl = imageUrl(of: existingCap.id, version: cap.mainImage)
|
||||||
if fm.fileExists(atPath: url.path) {
|
if exists(capImageUrl) {
|
||||||
updatedCap.mainImage = cap.mainImage
|
updatedCap.mainImage = cap.mainImage
|
||||||
}
|
}
|
||||||
if let color = cap.color {
|
if let color = cap.color {
|
||||||
@ -403,38 +508,89 @@ final class CapServer: ServerOwner {
|
|||||||
log("Updated cap \(existingCap.id)")
|
log("Updated cap \(existingCap.id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteImage(version: Int, for capId: Int) -> Bool {
|
func deleteImage(version: Int, for capId: Int) -> Cap? {
|
||||||
guard let cap = caps[capId] else {
|
guard let cap = caps[capId] else {
|
||||||
return false
|
log("Attempting to delete image \(version) of unknown cap \(capId)")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
let url = imageUrl(of: capId, version: version)
|
let capImageUrl = imageUrl(of: capId, version: version)
|
||||||
guard fm.fileExists(atPath: url.path) else {
|
guard exists(capImageUrl) else {
|
||||||
return false
|
log("Attempting to delete missing image \(version) of cap \(capId)")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
organizeImages(for: cap)
|
organizeImages(for: cap)
|
||||||
|
return caps[capId]!
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(cap capId: Int) -> Bool {
|
||||||
|
guard caps[capId] != nil else {
|
||||||
|
log("Attempting to delete unknown cap \(capId)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 1. Remove all images
|
||||||
|
do {
|
||||||
|
let imageFolderUrl = folder(of: capId)
|
||||||
|
if exists(imageFolderUrl) {
|
||||||
|
try fm.removeItem(at: imageFolderUrl)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log("Failed to delete image folder of cap \(capId): \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 2. Remove thumbnail
|
||||||
|
do {
|
||||||
|
let url = thumbnail(of: capId)
|
||||||
|
if exists(url) {
|
||||||
|
try fm.removeItem(at: url)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log("Failed to delete thumbnail of cap \(capId): \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove cap
|
||||||
|
caps[capId] = nil
|
||||||
|
saveCapCountHTML()
|
||||||
|
updateGridCapCount()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Classifier
|
// MARK: Classifier
|
||||||
|
|
||||||
func updateTrainedClasses(content: String) {
|
func saveTrainedClasses(content: String) throws {
|
||||||
let trainedCaps = content
|
let classes = content.components(separatedBy: ",")
|
||||||
.components(separatedBy: "\n")
|
|
||||||
.compactMap(Int.init)
|
// Validate input
|
||||||
let version = classifierVersion
|
try classes.forEach { s in
|
||||||
for cap in trainedCaps {
|
guard let id = Int(s) else {
|
||||||
if caps[cap]?.classifierVersion == nil {
|
log("Invalid id '\(s)' in uploaded id list")
|
||||||
caps[cap]?.classifierVersion = version
|
throw Abort(.badRequest)
|
||||||
|
}
|
||||||
|
guard caps[id] != nil else {
|
||||||
|
log("Unknown id '\(id)' in uploaded id list")
|
||||||
|
throw Abort(.badRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log("Updated \(trainedCaps.count) classifier classes")
|
|
||||||
|
guard let data = content.data(using: .utf8) else {
|
||||||
|
log("Failed to get classes data for writing")
|
||||||
|
throw Abort(.internalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try data.write(to: classifierClassesFile)
|
||||||
|
log("Updated \(classes.count) classifier classes")
|
||||||
|
} catch {
|
||||||
|
log("Failed to write classifier classes: \(error)")
|
||||||
|
throw Abort(.internalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(classifier: Data, version: Int) throws {
|
func save(classifier: Data, version: Int) throws {
|
||||||
do {
|
do {
|
||||||
try classifier.write(to: classifierFile)
|
try classifier.write(to: classifierFile)
|
||||||
} catch {
|
} catch {
|
||||||
log("Failed to write classifier: \(error)")
|
log("Failed to write classifier \(version): \(error)")
|
||||||
throw Abort(.internalServerError)
|
throw Abort(.internalServerError)
|
||||||
}
|
}
|
||||||
classifierVersion = version
|
classifierVersion = version
|
||||||
@ -444,7 +600,7 @@ final class CapServer: ServerOwner {
|
|||||||
// MARK: Grid
|
// MARK: Grid
|
||||||
|
|
||||||
func getListOfMissingThumbnails() -> [Int] {
|
func getListOfMissingThumbnails() -> [Int] {
|
||||||
caps.keys.filter { !fm.fileExists(atPath: thumbnail(of: $0).path) }
|
caps.keys.filter { !exists(thumbnail(of: $0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveThumbnail(_ data: Data, for cap: Int) {
|
func saveThumbnail(_ data: Data, for cap: Int) {
|
||||||
@ -466,82 +622,116 @@ final class CapServer: ServerOwner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: ServerOwner
|
func createMissingThumbnails() {
|
||||||
|
let thumbnailsToCreate = getListOfMissingThumbnails()
|
||||||
let authenticationMethod: PropertyAuthenticationMethod = .accessToken
|
guard !thumbnailsToCreate.isEmpty else {
|
||||||
|
return
|
||||||
func hasReadPermission(for property: UInt32, accessData: Data) -> Bool {
|
}
|
||||||
guard let key = String(data: accessData, encoding: .utf8) else {
|
guard canResizeImages else {
|
||||||
return false
|
log("Can't create thumbnails, missing ImageMagick")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log("Creating \(thumbnailsToCreate.count) thumbnails")
|
||||||
|
for cap in thumbnailsToCreate {
|
||||||
|
createThumbnail(for: cap)
|
||||||
}
|
}
|
||||||
return writers.contains(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasWritePermission(for property: UInt32, accessData: Data) -> Bool {
|
func createThumbnail(for cap: Int) {
|
||||||
guard let key = String(data: accessData, encoding: .utf8) else {
|
guard let version = caps[cap]?.mainImage else {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
return writers.contains(key)
|
let mainImageUrl = imageUrl(of: cap, version: version)
|
||||||
}
|
let thumbnailUrl = thumbnail(of: cap)
|
||||||
|
shrink(imageAt: mainImageUrl, size: thumbnailSize, destination: thumbnailUrl)
|
||||||
func hasListAccessPermission(_ accessData: Data) -> Bool {
|
|
||||||
guard let key = String(data: accessData, encoding: .utf8) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return writers.contains(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Monitoring
|
// MARK: Monitoring
|
||||||
|
|
||||||
public let name = "caps"
|
private let capCountMetric: Metric<Int>
|
||||||
|
|
||||||
private let capCountPropertyId = PropertyId(owner: "caps", uniqueId: 2)
|
private let imageCountMetric: Metric<Int>
|
||||||
|
|
||||||
private let imageCountPropertyId = PropertyId(owner: "caps", uniqueId: 3)
|
private let classifierMetric: Metric<Int>
|
||||||
|
|
||||||
private let classifierVersionPropertyId = PropertyId(owner: "caps", uniqueId: 4)
|
// MARK: Maintenance
|
||||||
|
|
||||||
func registerProperties(with monitor: PropertyManager) {
|
private func getMagickVersion() -> SemanticVersion? {
|
||||||
let capCountProperty = PropertyRegistration(
|
do {
|
||||||
uniqueId: capCountPropertyId.uniqueId,
|
let command = "convert -version"
|
||||||
name: "caps",
|
let (code, output) = try safeShell(command)
|
||||||
updates: .continuous,
|
guard code == 0,
|
||||||
isLogged: true,
|
let line = output.components(separatedBy: "\n").first,
|
||||||
allowsManualUpdate: false,
|
line.hasPrefix("Version: ImageMagick ") else {
|
||||||
read: { [weak self] in
|
log("Missing dependency ImageMagick: " + output)
|
||||||
return (self?.capCount ?? 0).timestamped()
|
return nil
|
||||||
})
|
}
|
||||||
monitor.register(capCountProperty, for: self)
|
guard let versionString = line
|
||||||
|
.replacingOccurrences(of: "Version: ImageMagick ", with: "")
|
||||||
let imageCountProperty = PropertyRegistration(
|
.components(separatedBy: "-").first else {
|
||||||
uniqueId: imageCountPropertyId.uniqueId,
|
log("Invalid ImageMagick version: " + output)
|
||||||
name: "images",
|
return nil
|
||||||
updates: .continuous,
|
}
|
||||||
isLogged: true,
|
guard let version = SemanticVersion(rawValue: versionString) else {
|
||||||
allowsManualUpdate: false,
|
log("Invalid ImageMagick version: " + output)
|
||||||
read: { [weak self] in
|
return nil
|
||||||
return (self?.imageCount ?? 0).timestamped()
|
}
|
||||||
})
|
return version
|
||||||
monitor.register(imageCountProperty, for: self)
|
} catch {
|
||||||
|
log("Failed to check dependency ImageMagick: \(error)")
|
||||||
let classifierVersionProperty = PropertyRegistration(
|
return nil
|
||||||
uniqueId: classifierVersionPropertyId.uniqueId,
|
}
|
||||||
name: "classifier",
|
|
||||||
updates: .continuous,
|
|
||||||
isLogged: true,
|
|
||||||
allowsManualUpdate: false,
|
|
||||||
read: { [weak self] in
|
|
||||||
return (self?.classifierVersion ?? 0).timestamped()
|
|
||||||
})
|
|
||||||
monitor.register(classifierVersionProperty, for: self)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMonitoredPropertiesOnCapChange() {
|
func shrinkImages() {
|
||||||
try? monitor.logChanged(property: capCountPropertyId, value: capCount.timestamped())
|
guard canResizeImages else {
|
||||||
try? monitor.logChanged(property: imageCountPropertyId, value: imageCount.timestamped())
|
log("Can't resize images, missing ImageMagick")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let imageFolders: [URL]
|
||||||
|
do {
|
||||||
|
imageFolders = try fm.contentsOfDirectory(at: imageFolder, includingPropertiesForKeys: nil)
|
||||||
|
} catch {
|
||||||
|
log("Failed to get all image folders")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for folder in imageFolders {
|
||||||
|
guard let images = try? self.images(in: folder) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for imageUrl in images {
|
||||||
|
shrink(imageAt: imageUrl, size: imageSize, destination: imageUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMonitoredClassifierVersionProperty() {
|
private func shrink(imageAt url: URL, size: Int, destination: URL) {
|
||||||
try? monitor.logChanged(property: classifierVersionPropertyId, value: classifierVersion.timestamped())
|
do {
|
||||||
|
let command = "convert \(url.path) -resize '\(size)x\(size)>' \(destination.path)"
|
||||||
|
let (code, output) = try safeShell(command)
|
||||||
|
if code != 0 {
|
||||||
|
log("Failed to shrink image \(url.path): " + output)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log("Failed to shrink image \(url.path): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func safeShell(_ command: String) throws -> (code: Int32, output: String) {
|
||||||
|
let task = Process()
|
||||||
|
let pipe = Pipe()
|
||||||
|
|
||||||
|
task.standardOutput = pipe
|
||||||
|
task.standardError = pipe
|
||||||
|
task.arguments = ["-cl", command]
|
||||||
|
task.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||||
|
task.standardInput = nil
|
||||||
|
|
||||||
|
try task.run()
|
||||||
|
task.waitUntilExit()
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let output = String(data: data, encoding: .utf8)!
|
||||||
|
return (task.terminationStatus, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,8 +8,11 @@ struct Config: Codable {
|
|||||||
/// The maximum size of the request body
|
/// The maximum size of the request body
|
||||||
let maxBodySize: String
|
let maxBodySize: String
|
||||||
|
|
||||||
/// The path to the log file
|
/// The path to the folder where the metric logs are stored
|
||||||
let logPath: String
|
///
|
||||||
|
/// If no path is provided, then a folder `logs` in the resources directory is created
|
||||||
|
/// If the path is relative, then it is assumed relative to the resources directory
|
||||||
|
let logPath: String?
|
||||||
|
|
||||||
/// Serve files in the Public directory using Vapor
|
/// Serve files in the Public directory using Vapor
|
||||||
let serveFiles: Bool
|
let serveFiles: Bool
|
||||||
@ -17,8 +20,28 @@ struct Config: Codable {
|
|||||||
/// Authentication tokens for remotes allowed to write
|
/// Authentication tokens for remotes allowed to write
|
||||||
let writers: [String]
|
let writers: [String]
|
||||||
|
|
||||||
var logURL: URL {
|
/**
|
||||||
.init(fileURLWithPath: logPath)
|
The folder where the data should be stored.
|
||||||
|
|
||||||
|
If the folder is set to `nil`, then the `Resources` folder is used.
|
||||||
|
*/
|
||||||
|
let dataDirectory: String?
|
||||||
|
|
||||||
|
func customDataDirectory(or publicDirectory: String) -> URL {
|
||||||
|
guard let dataDirectory else {
|
||||||
|
return URL(fileURLWithPath: publicDirectory)
|
||||||
|
}
|
||||||
|
return URL(fileURLWithPath: dataDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logURL(possiblyRelativeTo resourcesDirectory: URL) -> URL {
|
||||||
|
guard let logPath else {
|
||||||
|
return resourcesDirectory.appendingPathComponent("logs")
|
||||||
|
}
|
||||||
|
guard !logPath.hasPrefix("/") else {
|
||||||
|
return .init(fileURLWithPath: logPath)
|
||||||
|
}
|
||||||
|
return resourcesDirectory.appendingPathComponent(logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,12 +54,11 @@ extension Config {
|
|||||||
init(loadFrom directory: URL) {
|
init(loadFrom directory: URL) {
|
||||||
let configFileUrl = Config.file(in: directory)
|
let configFileUrl = Config.file(in: directory)
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: configFileUrl.path) {
|
guard FileManager.default.fileExists(atPath: configFileUrl.path) else {
|
||||||
self.init(loadAt: configFileUrl)
|
print("No configuration found at \(configFileUrl.path)")
|
||||||
} else {
|
exit(-1)
|
||||||
self.init(standardIn: directory)
|
|
||||||
write(to: configFileUrl)
|
|
||||||
}
|
}
|
||||||
|
self.init(loadAt: configFileUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(loadAt url: URL) {
|
private init(loadAt url: URL) {
|
||||||
@ -45,23 +67,7 @@ extension Config {
|
|||||||
self = try JSONDecoder().decode(Config.self, from: configData)
|
self = try JSONDecoder().decode(Config.self, from: configData)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to load configuration from \(url.path): \(error)")
|
print("Failed to load configuration from \(url.path): \(error)")
|
||||||
print("Using default configuration")
|
exit(-1)
|
||||||
self.init(standardIn: url.deletingLastPathComponent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private init(standardIn directory: URL) {
|
|
||||||
let defaultLogPath = directory.appendingPathComponent("logs").path
|
|
||||||
self.init(port: 8000, maxBodySize: "2mb", logPath: defaultLogPath, serveFiles: true, writers: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func write(to url: URL) {
|
|
||||||
do {
|
|
||||||
let configData = try JSONEncoder().encode(self)
|
|
||||||
try configData.write(to: url)
|
|
||||||
print("Configuration written at \(url.path)")
|
|
||||||
} catch {
|
|
||||||
print("Failed to write default configuration to \(url.path)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ enum CapError: Error {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
||||||
HTTP Code: 404
|
HTTP Code: 410
|
||||||
*/
|
*/
|
||||||
case unknownId
|
case unknownId
|
||||||
|
|
||||||
@ -46,10 +46,17 @@ enum CapError: Error {
|
|||||||
*/
|
*/
|
||||||
case invalidData
|
case invalidData
|
||||||
|
|
||||||
|
/**
|
||||||
|
The server failed to initialize the data and is not operational
|
||||||
|
|
||||||
|
HTTP Code: 204
|
||||||
|
*/
|
||||||
|
case serviceUnavailable
|
||||||
|
|
||||||
var response: HTTPResponseStatus {
|
var response: HTTPResponseStatus {
|
||||||
switch self {
|
switch self {
|
||||||
/// 404
|
/// 410
|
||||||
case .unknownId: return .notFound
|
case .unknownId: return .gone
|
||||||
/// 400
|
/// 400
|
||||||
case .invalidBody: return .badRequest
|
case .invalidBody: return .badRequest
|
||||||
/// 406
|
/// 406
|
||||||
@ -60,6 +67,7 @@ enum CapError: Error {
|
|||||||
case .invalidFile: return .preconditionFailed
|
case .invalidFile: return .preconditionFailed
|
||||||
/// 500
|
/// 500
|
||||||
case .invalidConfiguration: return .internalServerError
|
case .invalidConfiguration: return .internalServerError
|
||||||
|
case .serviceUnavailable: return .noContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
Sources/App/EventLoopScheduler.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
import Clairvoyant
|
||||||
|
import Vapor
|
||||||
|
import NIOCore
|
||||||
|
|
||||||
|
extension MultiThreadedEventLoopGroup: AsyncScheduler {
|
||||||
|
|
||||||
|
func test() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public func schedule(asyncJob: @escaping @Sendable () async throws -> Void) {
|
||||||
|
_ = any().makeFutureWithTask(asyncJob)
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,3 @@
|
|||||||
//
|
|
||||||
// Router+Extensions.swift
|
|
||||||
// App
|
|
||||||
//
|
|
||||||
// Created by Christoph on 05.05.20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Vapor
|
import Vapor
|
||||||
|
|
||||||
extension Application {
|
extension Application {
|
||||||
|
@ -1,49 +1,91 @@
|
|||||||
import Vapor
|
import Vapor
|
||||||
import Foundation
|
import Foundation
|
||||||
import Clairvoyant
|
import Clairvoyant
|
||||||
|
import ClairvoyantVapor
|
||||||
|
import ClairvoyantBinaryCodable
|
||||||
|
|
||||||
|
private var provider: VaporMetricProvider!
|
||||||
|
private var serverStatus: Metric<ServerStatus>!
|
||||||
|
|
||||||
private(set) var server: CapServer!
|
private let asyncScheduler = MultiThreadedEventLoopGroup(numberOfThreads: 2)
|
||||||
private(set) var monitor: PropertyManager!
|
|
||||||
|
|
||||||
public func configure(_ app: Application) throws {
|
private var server: CapServer!
|
||||||
|
|
||||||
|
func configure(_ app: Application) async throws {
|
||||||
|
|
||||||
let resourceDirectory = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
let resourceDirectory = URL(fileURLWithPath: app.directory.resourcesDirectory)
|
||||||
let publicDirectory = app.directory.publicDirectory
|
let publicDirectory = app.directory.publicDirectory
|
||||||
|
|
||||||
let config = Config(loadFrom: resourceDirectory)
|
let config = Config(loadFrom: resourceDirectory)
|
||||||
|
let authenticator = Authenticator(writers: config.writers)
|
||||||
|
|
||||||
server = CapServer(in: URL(fileURLWithPath: publicDirectory),
|
let logURL = config.logURL(possiblyRelativeTo: resourceDirectory)
|
||||||
writers: config.writers)
|
let monitor = MetricObserver(logFileFolder: logURL, logMetricId: "caps.log")
|
||||||
|
MetricObserver.standard = monitor
|
||||||
|
|
||||||
monitor = .init(logFolder: config.logURL, serverOwner: server)
|
serverStatus = Metric<ServerStatus>("caps.status",
|
||||||
monitor.update(status: .initializing)
|
name: "Status",
|
||||||
|
description: "The general status of the service")
|
||||||
server.registerProperties(with: monitor)
|
try await serverStatus.update(.initializing)
|
||||||
monitor.registerRoutes(app)
|
|
||||||
|
|
||||||
app.http.server.configuration.port = config.port
|
app.http.server.configuration.port = config.port
|
||||||
app.routes.defaultMaxBodySize = .init(stringLiteral: config.maxBodySize)
|
app.routes.defaultMaxBodySize = .init(stringLiteral: config.maxBodySize)
|
||||||
|
|
||||||
|
let dataDirectory = config.customDataDirectory(or: publicDirectory)
|
||||||
|
server = CapServer(in: dataDirectory)
|
||||||
|
|
||||||
|
provider = .init(observer: monitor, accessManager: config.writers)
|
||||||
|
provider.asyncScheduler = asyncScheduler
|
||||||
|
provider.registerRoutes(app)
|
||||||
|
|
||||||
if config.serveFiles {
|
if config.serveFiles {
|
||||||
let middleware = FileMiddleware(publicDirectory: publicDirectory)
|
let middleware = FileMiddleware(publicDirectory: publicDirectory)
|
||||||
app.middleware.use(middleware)
|
app.middleware.use(middleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register routes to the router
|
||||||
|
server.registerRoutes(with: app, authenticator: authenticator)
|
||||||
|
|
||||||
|
// Initialize the server data
|
||||||
do {
|
do {
|
||||||
try server.loadData()
|
try server.loadData()
|
||||||
} catch {
|
} catch {
|
||||||
monitor.update(status: .initializationFailure)
|
try await serverStatus.update(.initializationFailure)
|
||||||
|
print("[\(df.string(from: Date()))] Server failed to start: \(error)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if server.canResizeImages {
|
||||||
|
try await serverStatus.update(.nominal)
|
||||||
|
} else {
|
||||||
|
try await serverStatus.update(.reducedFunctionality)
|
||||||
|
}
|
||||||
|
print("[\(df.string(from: Date()))] Server started (\(app.environment.name), \(server.capCount) caps)")
|
||||||
|
}
|
||||||
|
|
||||||
// Register routes to the router
|
func shutdown() {
|
||||||
routes(app)
|
Task {
|
||||||
|
print("[\(df.string(from: Date()))] Server shutdown")
|
||||||
monitor.update(status: .nominal)
|
do {
|
||||||
|
try await asyncScheduler.shutdownGracefully()
|
||||||
|
} catch {
|
||||||
|
print("Failed to shut down MultiThreadedEventLoopGroup: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func log(_ message: String) {
|
func log(_ message: String) {
|
||||||
monitor.log(message)
|
guard let observer = MetricObserver.standard else {
|
||||||
print(message)
|
print(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asyncScheduler.schedule {
|
||||||
|
await observer.log(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let df: DateFormatter = {
|
||||||
|
let df = DateFormatter()
|
||||||
|
df.dateStyle = .short
|
||||||
|
df.timeStyle = .short
|
||||||
|
return df
|
||||||
|
}()
|
||||||
|
43
Sources/App/entrypoint.swift
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import Vapor
|
||||||
|
import Dispatch
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
/// This extension is temporary and can be removed once Vapor gets this support.
|
||||||
|
private extension Vapor.Application {
|
||||||
|
static let baseExecutionQueue = DispatchQueue(label: "vapor.codes.entrypoint")
|
||||||
|
|
||||||
|
func runFromAsyncMainEntrypoint() async throws {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
Vapor.Application.baseExecutionQueue.async { [self] in
|
||||||
|
do {
|
||||||
|
try self.run()
|
||||||
|
continuation.resume()
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
enum Entrypoint {
|
||||||
|
static func main() async throws {
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
|
||||||
|
let app = Application(env)
|
||||||
|
defer {
|
||||||
|
shutdown()
|
||||||
|
app.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await configure(app)
|
||||||
|
} catch {
|
||||||
|
app.logger.report(error: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await app.runFromAsyncMainEntrypoint()
|
||||||
|
}
|
||||||
|
}
|
@ -1,133 +0,0 @@
|
|||||||
import Vapor
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// The decoder to extract caps from JSON payloads given to the `cap` route.
|
|
||||||
private let decoder = JSONDecoder()
|
|
||||||
|
|
||||||
/// The date formatter to decode dates in requests
|
|
||||||
private let dateFormatter: DateFormatter = {
|
|
||||||
let df = DateFormatter()
|
|
||||||
df.dateFormat = "yy-MM-dd-HH-mm-ss"
|
|
||||||
return df
|
|
||||||
}()
|
|
||||||
|
|
||||||
private func authorize(_ request: Request) throws {
|
|
||||||
guard let key = request.headers.first(name: "key") else {
|
|
||||||
throw Abort(.badRequest) // 400
|
|
||||||
}
|
|
||||||
guard server.hasAuthorization(for: key) else {
|
|
||||||
throw Abort(.forbidden) // 403
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func routes(_ app: Application) {
|
|
||||||
|
|
||||||
app.get("version") { _ in
|
|
||||||
"\(server.classifierVersion)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add or change a cap
|
|
||||||
app.postCatching("cap") { request in
|
|
||||||
try authorize(request)
|
|
||||||
let data = try request.getBodyData()
|
|
||||||
let cap = try decoder.decode(Cap.self, from: data)
|
|
||||||
try server.addOrUpdate(cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload an image
|
|
||||||
app.postCatching("images", ":n") { request in
|
|
||||||
try authorize(request)
|
|
||||||
guard let cap = request.parameters.get("n", as: Int.self) else {
|
|
||||||
log("Invalid parameter for cap")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
let data = try request.getBodyData()
|
|
||||||
try server.save(image: data, for: cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the classifier
|
|
||||||
app.on(.POST, "classifier", ":version", body: .collect(maxSize: "50mb")) { request -> HTTPStatus in
|
|
||||||
try authorize(request)
|
|
||||||
guard let version = request.parameters.get("version", as: Int.self) else {
|
|
||||||
log("Invalid parameter for version")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
guard version > server.classifierVersion else {
|
|
||||||
throw Abort(.alreadyReported) // 208
|
|
||||||
}
|
|
||||||
let data = try request.getBodyData()
|
|
||||||
try server.save(classifier: data, version: version)
|
|
||||||
return .ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the trained classes
|
|
||||||
app.postCatching("classes", ":date") { request in
|
|
||||||
guard let dateString = request.parameters.get("date") else {
|
|
||||||
log("Invalid parameter for date")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
guard let date = dateFormatter.date(from: dateString) else {
|
|
||||||
log("Invalid date specification")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
try authorize(request)
|
|
||||||
let body = try request.getStringBody()
|
|
||||||
|
|
||||||
server.updateTrainedClasses(content: body)
|
|
||||||
server.removeAllEntriesInImageChangeList(before: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the list of missing thumbnails
|
|
||||||
app.get("thumbnails", "missing") { request in
|
|
||||||
let missingThumbnails = server.getListOfMissingThumbnails()
|
|
||||||
return missingThumbnails.map(String.init).joined(separator: ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload the thumbnail of a cap
|
|
||||||
app.postCatching("thumbnails", ":cap") { request in
|
|
||||||
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
|
||||||
log("Invalid cap parameter for thumbnail upload")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
try authorize(request)
|
|
||||||
let data = try request.getBodyData()
|
|
||||||
server.saveThumbnail(data, for: cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the image of a cap
|
|
||||||
app.postCatching("delete", ":cap", ":version") { request in
|
|
||||||
guard let cap = request.parameters.get("cap", as: Int.self) else {
|
|
||||||
log("Invalid cap parameter for image deletion")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
try authorize(request)
|
|
||||||
guard let version = request.parameters.get("version", as: Int.self) else {
|
|
||||||
log("Invalid version parameter for image deletion")
|
|
||||||
throw Abort(.badRequest)
|
|
||||||
}
|
|
||||||
guard server.deleteImage(version: version, for: cap) else {
|
|
||||||
throw Abort(.gone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Request {
|
|
||||||
|
|
||||||
func getBodyData() throws -> Data {
|
|
||||||
guard let buffer = body.data else {
|
|
||||||
log("Missing body data")
|
|
||||||
throw CapError.invalidBody
|
|
||||||
}
|
|
||||||
return Data(buffer: buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getStringBody() throws -> String {
|
|
||||||
let data = try getBodyData()
|
|
||||||
guard let content = String(data: data, encoding: .utf8) else {
|
|
||||||
log("Invalid string body")
|
|
||||||
throw CapError.invalidBody
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import App
|
|
||||||
import Vapor
|
|
||||||
|
|
||||||
var env = try Environment.detect()
|
|
||||||
try LoggingSystem.bootstrap(from: &env)
|
|
||||||
let app = Application(env)
|
|
||||||
defer { app.shutdown() }
|
|
||||||
try configure(app)
|
|
||||||
try app.run()
|
|
@ -1,13 +0,0 @@
|
|||||||
import App
|
|
||||||
import Dispatch
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class AppTests : XCTestCase {
|
|
||||||
func testNothing() throws {
|
|
||||||
XCTAssert(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allTests = [
|
|
||||||
("testNothing", testNothing),
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"contentDirectory": "../Public",
|
|
||||||
"trainingIterations": 20,
|
|
||||||
"serverPath": "https://mydomain.com/caps",
|
|
||||||
"authenticationToken": "mysecretkey",
|
|
||||||
}
|
|
@ -1,484 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Cocoa
|
|
||||||
import CreateML
|
|
||||||
|
|
||||||
struct Configuration: Codable {
|
|
||||||
|
|
||||||
let contentFolder: String
|
|
||||||
|
|
||||||
let trainingIterations: Int
|
|
||||||
|
|
||||||
let serverPath: String
|
|
||||||
|
|
||||||
let authenticationToken: String
|
|
||||||
|
|
||||||
init?(at url: URL) {
|
|
||||||
do {
|
|
||||||
let configData = try Data(contentsOf: url)
|
|
||||||
self = try JSONDecoder().decode(Configuration.self, from: configData)
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to load configuration at \(url.absoluteURL.path): \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Cap: Codable {
|
|
||||||
|
|
||||||
let id: Int
|
|
||||||
|
|
||||||
let count: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id = "i"
|
|
||||||
case count = "c"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ClassifierCreator {
|
|
||||||
|
|
||||||
static let configurationFileUrl = URL(fileURLWithPath: "config.json")
|
|
||||||
|
|
||||||
let server: URL
|
|
||||||
|
|
||||||
let configuration: Configuration
|
|
||||||
|
|
||||||
let imageDirectory: URL
|
|
||||||
|
|
||||||
let thumbnailDirectory: URL
|
|
||||||
|
|
||||||
let classifierUrl: URL
|
|
||||||
|
|
||||||
let df = DateFormatter()
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: Step 1: Load configuration
|
|
||||||
|
|
||||||
init?() {
|
|
||||||
guard let configuration = Configuration(at: ClassifierCreator.configurationFileUrl) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.configuration = configuration
|
|
||||||
guard let serverUrl = URL(string: configuration.serverPath) else {
|
|
||||||
print("[ERROR] Configuration: Invalid server path \(configuration.serverPath)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.server = serverUrl
|
|
||||||
let contentDirectory = URL(fileURLWithPath: configuration.contentFolder)
|
|
||||||
self.imageDirectory = contentDirectory.appendingPathComponent("images")
|
|
||||||
self.classifierUrl = contentDirectory.appendingPathComponent("classifier.mlmodel")
|
|
||||||
self.thumbnailDirectory = contentDirectory.appendingPathComponent("thumbnails")
|
|
||||||
df.dateFormat = "yy-MM-dd-HH-mm-ss"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Main function
|
|
||||||
|
|
||||||
func run() async {
|
|
||||||
let imagesSnapshotDate = Date()
|
|
||||||
guard let (classes, changedImageCount, changedMainImages) = await loadImages() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !classes.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard changedImageCount > 0 else {
|
|
||||||
print("[INFO] No changed images, so no new classifier trained")
|
|
||||||
await createThumbnails(changed: changedMainImages)
|
|
||||||
print("[INFO] Done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let classifierVersion = await getClassifierVersion() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let newVersion = classifierVersion + 1
|
|
||||||
|
|
||||||
print("[INFO] Image directory: \(imageDirectory.absoluteURL.path)")
|
|
||||||
print("[INFO] Model path: \(classifierUrl.path)")
|
|
||||||
print("[INFO] Version: \(newVersion)")
|
|
||||||
print("[INFO] Classes: \(classes.count)")
|
|
||||||
print("[INFO] Iterations: \(configuration.trainingIterations)")
|
|
||||||
|
|
||||||
guard trainModel() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard await upload(version: newVersion) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard await upload(classes: classes, lastUpdate: imagesSnapshotDate) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await createThumbnails(changed: changedMainImages)
|
|
||||||
print("[INFO] Done")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 2: Load changed images
|
|
||||||
|
|
||||||
func loadImages() async -> (classes: [Int], changedImageCount: Int, changedMainImages: [Int])? {
|
|
||||||
guard createImageFolderIfMissing() else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let imageCounts = await getImageCounts()
|
|
||||||
let missingImageList: [(cap: Int, image: Int)] = imageCounts
|
|
||||||
.sorted { $0.key < $1.key }
|
|
||||||
.reduce(into: []) { list, pair in
|
|
||||||
let missingImagesForCap: [(cap: Int, image: Int)] = (0..<pair.value).compactMap { image in
|
|
||||||
let url = imageUrl(base: imageDirectory, cap: pair.key, image: image)
|
|
||||||
guard !FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return (cap: pair.key, image: image)
|
|
||||||
}
|
|
||||||
list.append(contentsOf: missingImagesForCap)
|
|
||||||
}
|
|
||||||
if missingImageList.isEmpty {
|
|
||||||
print("[INFO] No missing images to load")
|
|
||||||
} else {
|
|
||||||
print("[INFO] Loading \(missingImageList.count) missing images...")
|
|
||||||
}
|
|
||||||
guard await loadImages(missingImageList) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let changedImageList = await getChangedImageList()
|
|
||||||
|
|
||||||
if changedImageList.isEmpty {
|
|
||||||
print("[INFO] No changed images to load")
|
|
||||||
} else {
|
|
||||||
print("[INFO] Loading \(changedImageList.count) changed images...")
|
|
||||||
}
|
|
||||||
guard await loadImages(changedImageList) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let changedMainImages = changedImageList.filter { $0.image == 0 }.map { $0.cap }
|
|
||||||
let classes = imageCounts.keys.sorted()
|
|
||||||
return (classes, missingImageList.count + changedImageList.count, changedMainImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createImageFolderIfMissing() -> Bool {
|
|
||||||
createFolderIfMissing(imageDirectory)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getImageCounts() async -> [Int : Int] {
|
|
||||||
guard let data: Data = await get(server.appendingPathComponent("caps.json")) else {
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
return try JSONDecoder().decode([Cap].self, from: data)
|
|
||||||
.reduce(into: [:]) { $0[$1.id] = $1.count }
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to decode cap database: \(error)")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func imageUrl(base: URL, cap: Int, image: Int) -> URL {
|
|
||||||
base.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, image))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImage(cap: Int, image: Int) async -> Bool {
|
|
||||||
let url = imageUrl(base: server.appendingPathComponent("images"), cap: cap, image: image)
|
|
||||||
let tempFile: URL, response: URLResponse
|
|
||||||
do {
|
|
||||||
(tempFile, response) = try await URLSession.shared.download(from: url)
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to load image \(image) of cap \(cap): \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let responseCode = (response as! HTTPURLResponse).statusCode
|
|
||||||
guard responseCode == 200 else {
|
|
||||||
print("[ERROR] Failed to load image \(image) of cap \(cap): Response \(responseCode)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let localUrl = imageUrl(base: imageDirectory, cap: cap, image: image)
|
|
||||||
if FileManager.default.fileExists(atPath: localUrl.path) {
|
|
||||||
try FileManager.default.removeItem(at: localUrl)
|
|
||||||
}
|
|
||||||
try FileManager.default.moveItem(at: tempFile, to: localUrl)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to save image \(image) of cap \(cap): \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadImages(_ list: [(cap: Int, image: Int)]) async -> Bool {
|
|
||||||
guard !list.isEmpty else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
var loadedImages = 0
|
|
||||||
await withTaskGroup(of: Bool.self) { group in
|
|
||||||
for (cap, image) in list {
|
|
||||||
group.addTask {
|
|
||||||
await self.loadImage(cap: cap, image: image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for await loaded in group {
|
|
||||||
if loaded {
|
|
||||||
loadedImages += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if loadedImages != list.count {
|
|
||||||
print("[ERROR] Only \(loadedImages) of \(list.count) images loaded")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func getChangedImageList() async -> [(cap: Int, image: Int)] {
|
|
||||||
guard let string: String = await get(server.appendingPathComponent("changes.txt")) else {
|
|
||||||
print("[ERROR] Failed to get list of changed images")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return string
|
|
||||||
.components(separatedBy: "\n")
|
|
||||||
.filter { $0 != "" }
|
|
||||||
.compactMap {
|
|
||||||
let parts = $0.components(separatedBy: ":")
|
|
||||||
guard parts.count == 3 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
guard let date = df.date(from: parts[0]) else {
|
|
||||||
print("[WARN] Invalid date \(parts[0]) in change list")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
guard let cap = Int(parts[1]) else {
|
|
||||||
print("[WARN] Invalid cap id \(parts[1]) in change list")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let image = Int(parts[2]) else {
|
|
||||||
print("[WARN] Invalid image id \(parts[2]) in change list")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return (cap, image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 3: Compute version
|
|
||||||
|
|
||||||
func getClassifierVersion() async -> Int? {
|
|
||||||
guard let string: String = await get(server.appendingPathComponent("version")) else {
|
|
||||||
print("[ERROR] Failed to get classifier version")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let version = Int(string) else {
|
|
||||||
print("[ERROR] Invalid classifier version \(string)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 4: Train classifier
|
|
||||||
|
|
||||||
func trainModel() -> Bool {
|
|
||||||
var params = MLImageClassifier.ModelParameters(augmentation: [])
|
|
||||||
params.maxIterations = configuration.trainingIterations
|
|
||||||
let model: MLImageClassifier
|
|
||||||
do {
|
|
||||||
model = try MLImageClassifier(
|
|
||||||
trainingData: .labeledDirectories(at: imageDirectory),
|
|
||||||
parameters: params)
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to create classifier: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
print("[INFO] Saving classifier...")
|
|
||||||
do {
|
|
||||||
try model.write(to: classifierUrl)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to save model to file: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 5: Upload classifier
|
|
||||||
|
|
||||||
func upload(version: Int) async -> Bool {
|
|
||||||
print("[INFO] Uploading classifier...")
|
|
||||||
let modelData: Data
|
|
||||||
do {
|
|
||||||
modelData = try Data(contentsOf: classifierUrl)
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to read classifier data: \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return await post(
|
|
||||||
url: server.appendingPathComponent("classifier/\(version)"),
|
|
||||||
body: modelData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 6: Update classes
|
|
||||||
|
|
||||||
func upload(classes: [Int], lastUpdate: Date) async -> Bool {
|
|
||||||
print("[INFO] Uploading trained classes...")
|
|
||||||
let dateString = df.string(from: lastUpdate)
|
|
||||||
return await post(
|
|
||||||
url: server.appendingPathComponent("classes/\(dateString)"),
|
|
||||||
body: classes.map(String.init).joined(separator: "\n").data(using: .utf8)!)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Step 7: Create thumbnails
|
|
||||||
|
|
||||||
func createThumbnails(changed: [Int]) async {
|
|
||||||
guard checkMagickAvailability() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard createFolderIfMissing(thumbnailDirectory) else {
|
|
||||||
print("[ERROR] Failed to create folder for thumbnails")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let capIdsOfMissingThumbnails = await getMissingThumbnailIds()
|
|
||||||
let all = Set(capIdsOfMissingThumbnails).union(changed)
|
|
||||||
print("[INFO] Creating \(all.count) thumbnails...")
|
|
||||||
for cap in all {
|
|
||||||
await createThumbnail(for: cap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkMagickAvailability() -> Bool {
|
|
||||||
do {
|
|
||||||
let (code, output) = try safeShell("magick --version")
|
|
||||||
guard code == 0, let version = output.components(separatedBy: "ImageMagick ").dropFirst().first?
|
|
||||||
.components(separatedBy: " ").first else {
|
|
||||||
print("[ERROR] Magick not found, install using 'brew install imagemagick'")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
print("[INFO] Using magick \(version)")
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to get version of magick: (\(error))")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getMissingThumbnailIds() async -> [Int] {
|
|
||||||
guard let string: String = await get(server.appendingPathComponent("thumbnails/missing")) else {
|
|
||||||
print("[ERROR] Failed to get missing thumbnails")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return string.components(separatedBy: ",").compactMap(Int.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createThumbnail(for cap: Int) async {
|
|
||||||
let inputUrl = imageUrl(base: imageDirectory, cap: cap, image: 0)
|
|
||||||
guard FileManager.default.fileExists(atPath: inputUrl.path) else {
|
|
||||||
print("[ERROR] Local main image not found for cap \(cap): \(inputUrl.path)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = thumbnailDirectory.appendingPathComponent(String(format: "%04d.jpg", cap))
|
|
||||||
do {
|
|
||||||
let command = "magick convert \(inputUrl.path) -quality 70% -resize 100x100 \(output.path)"
|
|
||||||
let (code, output) = try safeShell(command)
|
|
||||||
if code != 0 {
|
|
||||||
print("Failed to create thumbnail for cap \(cap): \(output)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Failed to read created thumbnail for cap \(cap): \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try Data(contentsOf: output)
|
|
||||||
} catch {
|
|
||||||
print("Failed to read created thumbnail for cap \(cap): \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard await post(url: server.appendingPathComponent("thumbnails/\(cap)"), body: data) else {
|
|
||||||
print("Failed to upload thumbnail for cap \(cap)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Helper
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func safeShell(_ command: String) throws -> (code: Int32, output: String) {
|
|
||||||
let task = Process()
|
|
||||||
let pipe = Pipe()
|
|
||||||
|
|
||||||
task.standardOutput = pipe
|
|
||||||
task.standardError = pipe
|
|
||||||
task.arguments = ["-cl", command]
|
|
||||||
task.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
|
||||||
task.standardInput = nil
|
|
||||||
|
|
||||||
try task.run()
|
|
||||||
task.waitUntilExit()
|
|
||||||
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let output = String(data: data, encoding: .utf8)!
|
|
||||||
return (task.terminationStatus, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private func createFolderIfMissing(_ folder: URL) -> Bool {
|
|
||||||
guard !FileManager.default.fileExists(atPath: folder.path) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Failed to create directory \(folder.path): \(error)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Requests
|
|
||||||
|
|
||||||
private func post(url: URL, body: Data) async -> Bool {
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBody = body
|
|
||||||
request.addValue(configuration.authenticationToken, forHTTPHeaderField: "key")
|
|
||||||
return await perform(request) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func perform(_ request: URLRequest) async -> Data? {
|
|
||||||
let data: Data
|
|
||||||
let response: URLResponse
|
|
||||||
do {
|
|
||||||
(data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
} catch {
|
|
||||||
print("[ERROR] Request failed: \(error)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let code = (response as! HTTPURLResponse).statusCode
|
|
||||||
guard code == 200 else {
|
|
||||||
print("[ERROR] Invalid response \(code)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private func get(_ url: URL) async -> Data? {
|
|
||||||
await perform(URLRequest(url: url))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func get(_ url: URL) async -> String? {
|
|
||||||
guard let data: Data = await get(url) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let string = String(data: data, encoding: .utf8) else {
|
|
||||||
print("[ERROR] Invalid string response \(data)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ClassifierCreator()?.run()
|
|
13
build.sh
@ -1,13 +0,0 @@
|
|||||||
echo "[1/7] Stopping server..."
|
|
||||||
sudo supervisorctl stop caps
|
|
||||||
echo "[2/7] Changing permissions..."
|
|
||||||
sudo chown -R pi:pi .
|
|
||||||
echo "[3/7] Pulling changes..."
|
|
||||||
git pull
|
|
||||||
echo "[4/7] Building project..."
|
|
||||||
swift build -c release
|
|
||||||
echo "[5/7] Restoring permissions..."
|
|
||||||
sudo chown -R www-data:www-data .
|
|
||||||
echo "[6/7] Starting server..."
|
|
||||||
sudo supervisorctl start caps
|
|
||||||
echo "[7/7] Done"
|
|