From bb27724a2a65e8f32ad19a4c69dbe39eeba536ea Mon Sep 17 00:00:00 2001 From: Florian Kostenzer Date: Wed, 25 Jan 2023 18:04:46 +0100 Subject: [PATCH] TileServerCacheUI (#8) * rewrite to vapor * add todo to tests * fix paths in docker file + fix wrong command for multistaticmap * improve ImageMagick error reporting * Update to Swift 5.2.3 to fix mem leak * add missing dependencies to Dockerfile * Add #format #pad and #round tags + add local marker support * Add #index(array, index) tag because leaf doesn't support subscripting yet * Add regeneratable support * fix # in color * fix encoding of collors * Better Error Reporting - Better error reporting - Add name to /styles call * Fix cache cleaner not cleaning * undo debug to verbose logging change * Some Cleanup * Missing File * some testing and fixes * More Cleanup & Fix Cleaning * Add POST support to for templates * Update README with new routes * Add support for 3rd party providers (e.g. MapBox, Google Maps) for tiles and static maps * Fix templating problem with new leaf version * add "fallback_url" to markers * fix hit ratio stats for tiles * skip markers outside of view * Limit markers to content type "image/*" * Clear template cache on changes, add circles, fixes * initial TileServerCacheUI implementation * Fix default config.json * add MAX_BODY_SIZE option * Add Maputnik link * Show missing fonts/icons + auto unify font names on import * Fix External Styles * Fix Fonts and Icons Analysis * feat: add regeneratable cache cleaner * fix: fix cache cleaner paths * fix: fix initial setup * refactor: remove unused which command from Dockerfile * fix: properly escape shell commands * fix: fix mbtiles combining * fix: fix local image check for fallback urls * fix: clear affected images if imagemagick fails * refactor: remove file headers * doc: enable markers volume by default * build: pin build dependencies to working versions * fix: only copy config when ui is enabled * feat: update to swift 5.7 * fix: correct templates view heading * feat: add support to upload zipped styles * feat: add support to upload `.mbtiles` files directly --- .gitignore | 4 + Dockerfile | 19 +- Package.swift | 16 +- Resources/TileServer/Empty.mbtiles | 1 + Resources/TileServer/config.json | 20 ++ Resources/Views/Base.leaf | 53 +++ Resources/Views/Datasets.leaf | 63 ++++ Resources/Views/DatasetsAdd.leaf | 116 +++++++ Resources/Views/DatasetsDelete.leaf | 62 ++++ Resources/Views/Fonts.leaf | 71 ++++ Resources/Views/FontsAdd.leaf | 75 ++++ Resources/Views/Stats.leaf | 45 +-- Resources/Views/Styles.leaf | 123 +++++++ Resources/Views/StylesAddExternal.leaf | 54 +++ Resources/Views/StylesAddLocal.leaf | 114 ++++++ Resources/Views/StylesDeleteLocal.leaf | 62 ++++ Resources/Views/Templates.leaf | 72 ++++ Resources/Views/TemplatesEdit.leaf | 175 ++++++++++ .../Controller/DatasetsController.swift | 125 +++++++ .../Controller/FontsController.swift | 66 ++++ .../Controller/MultiStaticMapController.swift | 9 +- .../Controller/StaticMapController.swift | 17 +- .../Controller/StatsController.swift | 71 +--- .../Controller/StylesController.swift | 325 ++++++++++++++++++ .../Controller/TemplatesController.swift | 94 +++++ .../Controller/TileController.swift | 17 +- .../LeafTag/FormatTag.swift | 9 +- .../LeafTag/IndexTag.swift | 9 +- .../SwiftTileserverCache/LeafTag/PadTag.swift | 9 +- .../LeafTag/RoundTag.swift | 9 +- .../SwiftTileserverCache/Misc/APIUtils.swift | 9 +- .../Misc/AdminAuthenticator.swift | 32 ++ .../Misc/CacheCleaner.swift | 23 +- .../Misc/FileToucher.swift | 7 - .../Misc/ImageUtils.swift | 24 +- .../Misc/LeafCacheCleaner.swift | 7 - .../Misc/LeafData+Decodable.swift | 7 - .../Misc/ResponseUtils.swift | 7 - .../Misc/SphericalMercator.swift | 8 +- .../Misc/String+BashEscaped.swift | 7 - .../Misc/String+CamelCase.swift | 13 + .../Misc/String+IsEmpty.swift | 7 + .../Misc/StringArray+BashEscaped.swift | 7 - .../SwiftTileserverCache/Model/Circle.swift | 7 - .../Model/CombineDirection.swift | 7 - .../SwiftTileserverCache/Model/Drawable.swift | 7 - .../SwiftTileserverCache/Model/HitRatio.swift | 7 - .../Model/ImageFormat.swift | 7 - .../SwiftTileserverCache/Model/Marker.swift | 7 - .../Model/MultiStaticMap.swift | 7 - .../Model/PersistentHashable.swift | 7 - .../SwiftTileserverCache/Model/Polygon.swift | 7 - .../Model/StaticMap.swift | 7 - .../SwiftTileserverCache/Model/Style.swift | 20 +- .../DatasetsAddViewController.swift | 22 ++ .../DatasetsDeleteController.swift | 24 ++ .../DatasetsViewController.swift | 34 ++ .../FontsAddViewController.swift | 22 ++ .../ViewController/FontsViewController.swift | 34 ++ .../ViewController/StatsViewController.swift | 45 +++ .../StylesAddExternalViewController.swift | 22 ++ .../StylesAddLocalViewController.swift | 32 ++ .../StylesDeleteLocalViewController.swift | 24 ++ .../ViewController/StylesViewController.swift | 40 +++ .../TemplatesEditViewController.swift | 37 ++ .../TemplatesViewController.swift | 34 ++ .../ViewController/ViewController.swift | 18 + .../SwiftTileserverCache/cachecleaners.swift | 54 +-- Sources/SwiftTileserverCache/configure.swift | 1 + Sources/SwiftTileserverCache/routes.swift | 75 +++- Sources/SwiftTileserverCache/tileserver.swift | 11 + Sources/SwiftTileserverCacheApp/main.swift | 7 - docker-compose.yml | 15 +- 73 files changed, 2356 insertions(+), 348 deletions(-) create mode 100644 Resources/TileServer/Empty.mbtiles create mode 100644 Resources/TileServer/config.json create mode 100644 Resources/Views/Base.leaf create mode 100644 Resources/Views/Datasets.leaf create mode 100644 Resources/Views/DatasetsAdd.leaf create mode 100644 Resources/Views/DatasetsDelete.leaf create mode 100644 Resources/Views/Fonts.leaf create mode 100644 Resources/Views/FontsAdd.leaf create mode 100644 Resources/Views/Styles.leaf create mode 100644 Resources/Views/StylesAddExternal.leaf create mode 100644 Resources/Views/StylesAddLocal.leaf create mode 100644 Resources/Views/StylesDeleteLocal.leaf create mode 100644 Resources/Views/Templates.leaf create mode 100644 Resources/Views/TemplatesEdit.leaf create mode 100644 Sources/SwiftTileserverCache/Controller/DatasetsController.swift create mode 100644 Sources/SwiftTileserverCache/Controller/FontsController.swift create mode 100644 Sources/SwiftTileserverCache/Controller/StylesController.swift create mode 100644 Sources/SwiftTileserverCache/Controller/TemplatesController.swift create mode 100644 Sources/SwiftTileserverCache/Misc/AdminAuthenticator.swift create mode 100644 Sources/SwiftTileserverCache/Misc/String+CamelCase.swift create mode 100644 Sources/SwiftTileserverCache/Misc/String+IsEmpty.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/DatasetsAddViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/DatasetsDeleteController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/DatasetsViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/FontsAddViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/FontsViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/StatsViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/StylesAddExternalViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/StylesAddLocalViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/StylesDeleteLocalViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/StylesViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/TemplatesEditViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/TemplatesViewController.swift create mode 100644 Sources/SwiftTileserverCache/ViewController/ViewController.swift create mode 100644 Sources/SwiftTileserverCache/tileserver.swift diff --git a/.gitignore b/.gitignore index 2c5ff4a..a4c55b2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ Package.resolved /Templates /Markers /TileServer +/Temp .swiftpm/ +.env +.env.development +docker-compose.development.yml diff --git a/Dockerfile b/Dockerfile index 9c9c3c3..21ca9fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ================================ # Build image # ================================ -FROM swift:5.2 as build +FROM swift:5.7 as build WORKDIR /build # Copy required folders into container @@ -19,12 +19,27 @@ RUN swift build \ # ================================ # Run image # ================================ -FROM swift:5.2 +FROM swift:5.7 WORKDIR /SwiftTileserverCache # Install imagemagick RUN apt-get -y update && apt-get install -y imagemagick +# Install tippecanoe requirements +RUN apt-get -y update && apt-get -y install build-essential libsqlite3-dev zlib1g-dev + +RUN git clone https://github.com/mapbox/tippecanoe.git -b 1.36.0 \ + && cd tippecanoe \ + && make -j \ + && make install \ + && rm -rf tippecanoe + +# Install fontnik requirements +RUN apt-get -y update && apt-get -y install nodejs npm + + # Install fontnik +RUN npm install -g fontnik@0.6.0 + # Copy build artifacts COPY --from=build /build/.build/release /SwiftTileserverCache # Copy Resources diff --git a/Package.swift b/Package.swift index 2eb027a..9edba3d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,17 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.7 import PackageDescription let package = Package( name: "SwiftTileserverCache", platforms: [ - .macOS(.v10_15) // linux does not yet have runntime availability checks so this doesn't apply to linux yet + .macOS(.v12) // linux does not yet have runtime availability checks so this doesn't apply to linux yet ], dependencies: [ - .package(url: "https://github.com/vapor/vapor", from: "4.0.0"), - .package(url: "https://github.com/vapor/leaf", from: "4.0.0"), - .package(url: "https://github.com/JohnSundell/ShellOut", from: "2.3.0") + .package(url: "https://github.com/vapor/vapor", .upToNextMinor(from: "4.69.1")), + .package(url: "https://github.com/vapor/leaf", .upToNextMinor(from: "4.2.4")), + .package(url: "https://github.com/JohnSundell/ShellOut", .upToNextMinor(from: "2.3.0")), + .package(url: "https://github.com/weichsel/ZIPFoundation.git", .upToNextMajor(from: "0.9.16")) ], targets: [ .target( @@ -18,10 +19,11 @@ let package = Package( dependencies: [ .product(name: "Vapor", package: "vapor"), .product(name: "Leaf", package: "leaf"), - .product(name: "ShellOut", package: "ShellOut") + .product(name: "ShellOut", package: "ShellOut"), + .product(name: "ZIPFoundation", package: "ZIPFoundation") ] ), - .target( + .executableTarget( name: "SwiftTileserverCacheApp", dependencies: [ .target(name: "SwiftTileserverCache"), diff --git a/Resources/TileServer/Empty.mbtiles b/Resources/TileServer/Empty.mbtiles new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Resources/TileServer/Empty.mbtiles @@ -0,0 +1 @@ + diff --git a/Resources/TileServer/config.json b/Resources/TileServer/config.json new file mode 100644 index 0000000..ec3edb1 --- /dev/null +++ b/Resources/TileServer/config.json @@ -0,0 +1,20 @@ +{ + "options": { + "paths": { + "root": "/usr/src/app/node_modules/tileserver-gl-styles", + "fonts": "/data/Fonts", + "styles": "/data/Styles", + "sprites": "/data/Styles", + "mbtiles": "/data/Datasets" + }, + "serveAllFonts": true, + "serveAllStyles": true, + "serveStaticMaps": true + }, + "styles": {}, + "data": { + "combined": { + "mbtiles": "Combined.mbtiles" + } + } +} diff --git a/Resources/Views/Base.leaf b/Resources/Views/Base.leaf new file mode 100644 index 0000000..07fba2b --- /dev/null +++ b/Resources/Views/Base.leaf @@ -0,0 +1,53 @@ + + + + SwiftTileserverCache #if(pageName): - #(pageName)#endif + + + + + + + + #import("stylesheets") + + + + + + + + #import("scripts") + + + + +
+ #import("content") +
+ + diff --git a/Resources/Views/Datasets.leaf b/Resources/Views/Datasets.leaf new file mode 100644 index 0000000..f9cb782 --- /dev/null +++ b/Resources/Views/Datasets.leaf @@ -0,0 +1,63 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Datasets

+
+
+ + + + + + + + + #for(dataset in datasets): + + + + + #endfor + +
DatasetActions
+ #(dataset) + +
+
+
+
+ #endexport +#endextend diff --git a/Resources/Views/DatasetsAdd.leaf b/Resources/Views/DatasetsAdd.leaf new file mode 100644 index 0000000..88d1c57 --- /dev/null +++ b/Resources/Views/DatasetsAdd.leaf @@ -0,0 +1,116 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Add Dataset

+
+
+
+
+ Dataset Name + +
+
Specify either a URL to download or a File to upload. (Download from OpenMapTiles)
+
+
+ Dataset URL + +
+
+ Dataset File + +
+
+ +
+
+ + #endexport +#endextend diff --git a/Resources/Views/DatasetsDelete.leaf b/Resources/Views/DatasetsDelete.leaf new file mode 100644 index 0000000..febd1d2 --- /dev/null +++ b/Resources/Views/DatasetsDelete.leaf @@ -0,0 +1,62 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Delete Dataset #(datasetName)

+
+ + #endexport +#endextend diff --git a/Resources/Views/Fonts.leaf b/Resources/Views/Fonts.leaf new file mode 100644 index 0000000..0f7501e --- /dev/null +++ b/Resources/Views/Fonts.leaf @@ -0,0 +1,71 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Fonts

+
+
+ + + + + + + + + #for(font in fonts): + + + + + #endfor + +
FontActions
+ #(font) + +
+
+
+
+ #endexport +#endextend diff --git a/Resources/Views/FontsAdd.leaf b/Resources/Views/FontsAdd.leaf new file mode 100644 index 0000000..ec2a323 --- /dev/null +++ b/Resources/Views/FontsAdd.leaf @@ -0,0 +1,75 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Add Fonts

+
+
+
+
+ Fonts + +
+ +
+
+ + #endexport +#endextend diff --git a/Resources/Views/Stats.leaf b/Resources/Views/Stats.leaf index 0146649..52c99d1 100644 --- a/Resources/Views/Stats.leaf +++ b/Resources/Views/Stats.leaf @@ -1,21 +1,24 @@ - - - - - SwiftTileserver Cache - - -

Swift Tileserver Cache


-

Tiles Cache Hit-Rate (since restart)

- #for(tileHitRatio in tileHitRatios): -

#(tileHitRatio.key): #(tileHitRatio.value)

- #endfor -

Static Map Cache Hit-Rate (since restart)

- #for(staticMapHitRatio in staticMapHitRatios): -

#(staticMapHitRatio.key): #(staticMapHitRatio.value)

- #endfor -

Marker Cache Hit-Rate (since restart)

- #for(markerHitRatio in markerHitRatios): -

#(markerHitRatio.key): #(markerHitRatio.value)

- #endfor - +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + #endexport + #export("content"): +

Stats

+
+

Tiles Cache Hit-Rate (since restart)

+ #for(tileHitRatio in tileHitRatios): +

#(tileHitRatio.key): #(tileHitRatio.value)

+ #endfor +
+

Static Map Cache Hit-Rate (since restart)

+ #for(staticMapHitRatio in staticMapHitRatios): +

#(staticMapHitRatio.key): #(staticMapHitRatio.value)

+ #endfor +
+

Marker Cache Hit-Rate (since restart)

+ #for(markerHitRatio in markerHitRatios): +

#(markerHitRatio.key): #(markerHitRatio.value)

+ #endfor + #endexport +#endextend diff --git a/Resources/Views/Styles.leaf b/Resources/Views/Styles.leaf new file mode 100644 index 0000000..bac1f04 --- /dev/null +++ b/Resources/Views/Styles.leaf @@ -0,0 +1,123 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Styles

+
+
+ + + + + + + + + + + + + + #for(style in styles): + + + + + + + + + + #endfor + +
NameIDTypeFontsIconsPreviewActions
+ #(style.name) + + #(style.id) + + #if(style.external): + External + #else: + Local + #endif + + #if(style.analysis != nil): + #if(count(style.analysis.missingFonts) == 0): + OK + #else: + Missing:
+ #for(font in style.analysis.missingFonts): + - #(font)
+ #endfor + #endif + #endif +
+ #if(style.analysis != nil): + #if(count(style.analysis.missingIcons) == 0): + OK + #else: + Missing:
+ #for(font in style.analysis.missingIcons): + - #(font)
+ #endfor + #endif + #endif +
+ + +
+
+
+
+ #endexport +#endextend diff --git a/Resources/Views/StylesAddExternal.leaf b/Resources/Views/StylesAddExternal.leaf new file mode 100644 index 0000000..1987c92 --- /dev/null +++ b/Resources/Views/StylesAddExternal.leaf @@ -0,0 +1,54 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Add External Style

+
+
+
+
+ Style Name + +
+
+ Style ID + +
+
+ Tile URL + +
+ +
+
+ #endexport +#endextend diff --git a/Resources/Views/StylesAddLocal.leaf b/Resources/Views/StylesAddLocal.leaf new file mode 100644 index 0000000..e9c64e9 --- /dev/null +++ b/Resources/Views/StylesAddLocal.leaf @@ -0,0 +1,114 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Add Local Style

+
+
+
+
+ Name + +
+
+ ID + +
+
+ Files (Design Custom Styles) [Required: style.json, sprite.json, sprite.json, sprite.png, sprite@2x.json, sprite@2x.png (or in a zip file)] + +
+ +
+
+ + #endexport +#endextend diff --git a/Resources/Views/StylesDeleteLocal.leaf b/Resources/Views/StylesDeleteLocal.leaf new file mode 100644 index 0000000..f3e4175 --- /dev/null +++ b/Resources/Views/StylesDeleteLocal.leaf @@ -0,0 +1,62 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Delete Local Style #(styleId)

+
+ + #endexport +#endextend diff --git a/Resources/Views/Templates.leaf b/Resources/Views/Templates.leaf new file mode 100644 index 0000000..e84e9c7 --- /dev/null +++ b/Resources/Views/Templates.leaf @@ -0,0 +1,72 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + #endexport + #export("scripts"): + + #endexport + #export("content"): +

Templates

+
+
+ + + + + + + + + #for(template in templates): + + + + + #endfor + +
DatasetActions
+ #(template) + +
+ Edit +
+
+
+ #endexport +#endextend diff --git a/Resources/Views/TemplatesEdit.leaf b/Resources/Views/TemplatesEdit.leaf new file mode 100644 index 0000000..5784812 --- /dev/null +++ b/Resources/Views/TemplatesEdit.leaf @@ -0,0 +1,175 @@ +#extend("Resources/Views/Base"): + #export("stylesheets"): + + #endexport + #export("scripts"): + + #endexport + #export("content"): +

#(pageName)

+
+
+
+
+
+
+
+ Template Name + +
+
+ Template Content +
#(templateContent)
+
+ +
+
+
+ Test Data +
+
+
+ Preview + + +
+
+
+
+
+
+ #endexport +#endextend diff --git a/Sources/SwiftTileserverCache/Controller/DatasetsController.swift b/Sources/SwiftTileserverCache/Controller/DatasetsController.swift new file mode 100644 index 0000000..824a948 --- /dev/null +++ b/Sources/SwiftTileserverCache/Controller/DatasetsController.swift @@ -0,0 +1,125 @@ +import Vapor + +internal class DatasetsController { + + struct SaveDataset: Content { + var name: String + var file: File + } + + private static let tileJoinCommand = "/usr/local/bin/tile-join" + + private let folder: String + private let listFolder: String + + internal init(folder: String) { + self.folder = folder + self.listFolder = folder + "/List" + try? FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(atPath: listFolder, withIntermediateDirectories: true) + } + + // MARK: - Routes + + internal func download(request: Request, websocket: WebSocket) -> () { + websocket.onText { (websocket, text) in + let split = text.components(separatedBy: ";") + guard split.count == 2, let url = URL(string: split[1]) else { + return websocket.send("Invalid URL") + } + let name = split[0] + request.logger.info("Downloading \(name).mbtiles (\(url.absoluteString))") + APIUtils.downloadFile(request: request, from: url.absoluteString, to: self.listFolder + "/\(name).mbtiles", type: nil).whenComplete { (result) in + switch result { + case .success: + request.logger.info("Downloading \(name).mbtiles done") + websocket.send("downloaded") + request.logger.info("Combining mbtiles") + self.combineTiles(request: request).whenComplete { (result) in + switch result { + case .success: + request.logger.info("Combining mbtiles done") + websocket.send("ok") + case .failure(let error): + request.logger.error("Combining mbtiles failed: \(error.localizedDescription)") + websocket.send(error.localizedDescription) + } + } + case .failure(let error): + request.logger.error("Downloading \(name).mbtiles failed: \(error.localizedDescription)") + websocket.send(error.localizedDescription) + } + } + } + } + + internal func delete(request: Request, websocket: WebSocket) -> () { + websocket.onText { (websocket, text) in + let name = text + do { + try FileManager.default.removeItem(atPath: self.listFolder + "/\(name).mbtiles") + } catch { + request.logger.error("Failed to delete \(name).mbtiles: \(error.localizedDescription)") + websocket.send(error.localizedDescription) + return + } + websocket.send("deleted") + request.logger.info("Combining mbtiles") + self.combineTiles(request: request).whenComplete { (result) in + switch result { + case .success: + request.logger.info("Combining mbtiles done") + websocket.send("ok") + case .failure(let error): + request.logger.error("Combining mbtiles failed: \(error.localizedDescription)") + websocket.send("Combining mbtiles failed: \(error.localizedDescription)") + } + } + } + } + + internal func add(request: Request) throws -> EventLoopFuture { + let dataset = try request.content.decode(SaveDataset.self) + return request.fileio.writeFile(dataset.file.data, at: self.listFolder + "/\(dataset.name).mbtiles").flatMap { + self.combineTiles(request: request).map { .ok } + } + } + + // MARK: - Utils + + internal func getDatasets() throws -> [String] { + return try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: listFolder), includingPropertiesForKeys: nil) + .filter({ $0.pathExtension == "mbtiles" }) + .map({ $0.deletingPathExtension().lastPathComponent }) + } + + private func combineTiles(request: Request) -> EventLoopFuture { + let datasets: [String] + do { + datasets = try getDatasets() + } catch { + return request.eventLoop.makeFailedFuture("Failed to get mbtiles: \(error.localizedDescription)") + } + if datasets.count == 0 { + return request.eventLoop.makeSucceededFuture(()) + } else if datasets.count == 1 { + try? FileManager.default.removeItem(atPath: self.folder + "/Combined.mbtiles") + do { + try escapedShellOut(to: "/bin/ln", arguments: ["-s", "List/\(datasets[0]).mbtiles", "Combined.mbtiles"], at: self.folder) + } catch { + return request.eventLoop.makeFailedFuture("Failed to link mbtiles: \(error.localizedDescription)") + } + return request.eventLoop.future() + } else { + return request.application.threadPool.runIfActive(eventLoop: request.eventLoop) { + do { + let files = datasets.map({ "List/\($0).mbtiles" }) + try escapedShellOut(to: DatasetsController.tileJoinCommand, arguments: ["--force", "-o", "Combined.mbtiles"] + files, at: self.folder) + } catch { + throw Abort(.internalServerError, reason: "Failed to get mbtiles: \(error.localizedDescription)") + } + } + } + } + +} diff --git a/Sources/SwiftTileserverCache/Controller/FontsController.swift b/Sources/SwiftTileserverCache/Controller/FontsController.swift new file mode 100644 index 0000000..f6b99ff --- /dev/null +++ b/Sources/SwiftTileserverCache/Controller/FontsController.swift @@ -0,0 +1,66 @@ +import Vapor + +internal class FontsController { + + struct SaveFont: Content { + var file: File + } + + #if os(macOS) + private static let buildGlyphsCommand = "/usr/local/opt/node/bin/node /usr/local/bin/build-glyphs" + #else + private static let buildGlyphsCommand = "/usr/local/bin/build-glyphs" + #endif + + private let folder: String + private let tempFolder: String + + internal init(folder: String, tempFolder: String) { + self.folder = folder + self.tempFolder = tempFolder + try? FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + try? FileManager.default.removeItem(atPath: tempFolder) + try? FileManager.default.createDirectory(atPath: tempFolder, withIntermediateDirectories: true) + } + + // MARK: - Routes + + internal func add(request: Request) throws -> EventLoopFuture { + let font = try request.content.decode(SaveFont.self) + let tempFile = "\(tempFolder)/\(UUID().uuidString).\(font.file.extension ?? "tff")" + let name = font.file.filename.split(separator: ".").dropLast().joined(separator: ".").toCamelCase + return request.fileio.writeFile(font.file.data, at: tempFile).flatMap { _ in + return self.buildGlyphs(request: request, file: tempFile, name: name).map { _ in + return Response(status: .ok) + } + } + } + + internal func delete(request: Request) throws -> Response { + try FileManager.default.removeItem(atPath: "\(folder)/\(request.parameters.get("name") ?? "")") + return Response(status: .ok) + } + + // MARK: - Utils + + internal func getFonts() throws -> [String] { + return try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: folder), includingPropertiesForKeys: nil) + .filter({ $0.hasDirectoryPath }) + .map({ $0.deletingPathExtension().lastPathComponent }) + } + + private func buildGlyphs(request: Request, file: String, name: String) -> EventLoopFuture { + let path = "\(folder)/\(name)" + return request.application.threadPool.runIfActive(eventLoop: request.eventLoop) { + try? FileManager.default.removeItem(atPath: path) + do { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false) + try escapedShellOut(to: FontsController.buildGlyphsCommand, arguments: [file, path]) + } catch { + try? FileManager.default.removeItem(atPath: path) + throw Abort(.internalServerError, reason: "Failed to create glyphs: \(error.localizedDescription)") + } + } + } + +} diff --git a/Sources/SwiftTileserverCache/Controller/MultiStaticMapController.swift b/Sources/SwiftTileserverCache/Controller/MultiStaticMapController.swift index 060a248..00c5598 100644 --- a/Sources/SwiftTileserverCache/Controller/MultiStaticMapController.swift +++ b/Sources/SwiftTileserverCache/Controller/MultiStaticMapController.swift @@ -1,10 +1,3 @@ -// -// MultiStaticMapController.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 08.05.20. -// - import Vapor import Leaf @@ -55,7 +48,7 @@ internal class MultiStaticMapController { // MARK: - Utils - private func handleRequest(request: Request, multiStaticMap: MultiStaticMap) -> EventLoopFuture { + internal func handleRequest(request: Request, multiStaticMap: MultiStaticMap) -> EventLoopFuture { let path = multiStaticMap.path if !FileManager.default.fileExists(atPath: path) { return generateStaticMapAndResponse(request: request, path: path, multiStaticMap: multiStaticMap).always { result in diff --git a/Sources/SwiftTileserverCache/Controller/StaticMapController.swift b/Sources/SwiftTileserverCache/Controller/StaticMapController.swift index 0039f96..818bb52 100644 --- a/Sources/SwiftTileserverCache/Controller/StaticMapController.swift +++ b/Sources/SwiftTileserverCache/Controller/StaticMapController.swift @@ -1,27 +1,20 @@ -// -// StaticMapController.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 08.05.20. -// - import Vapor import Leaf internal class StaticMapController { private let tileServerURL: String - private let tiles: [String: String] private let tileController: TileController private let statsController: StatsController + private let stylesController: StylesController private let sphericalMercator = SphericalMercator() - init(tileServerURL: String, tiles: [(style: Style, url: String)], tileController: TileController, statsController: StatsController) { + init(tileServerURL: String, tileController: TileController, statsController: StatsController, stylesController: StylesController) { self.tileServerURL = tileServerURL - self.tiles = tiles.reduce(into: [String: String](), { $0[$1.style.id] = $1.url }) self.tileController = tileController self.statsController = statsController + self.stylesController = stylesController } // MARK: - Routes @@ -71,7 +64,7 @@ internal class StaticMapController { // MARK: - Utils - private func handleRequest(request: Request, staticMap: StaticMap) -> EventLoopFuture { + internal func handleRequest(request: Request, staticMap: StaticMap) -> EventLoopFuture { let path = staticMap.path if !FileManager.default.fileExists(atPath: path) { return generateStaticMapAndResponse(request: request, path: path, staticMap: staticMap).always { result in @@ -150,7 +143,7 @@ internal class StaticMapController { } private func loadBaseStaticMap(request: Request, path: String, staticMap: StaticMap) -> EventLoopFuture { - if let url = tiles[staticMap.style] { + if let url = stylesController.getExternalStyle(name: staticMap.style)?.url { let hasScale = url.contains("{@scale}") || url.contains("{scale}") return generateBaseStaticMap(request: request, path: path, staticMap: staticMap, hasScale: hasScale) } diff --git a/Sources/SwiftTileserverCache/Controller/StatsController.swift b/Sources/SwiftTileserverCache/Controller/StatsController.swift index 5b08b51..23d89aa 100644 --- a/Sources/SwiftTileserverCache/Controller/StatsController.swift +++ b/Sources/SwiftTileserverCache/Controller/StatsController.swift @@ -1,17 +1,8 @@ -// -// StatsController.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 08.05.20. -// - import Vapor import Leaf internal class StatsController { - private let tileServerURL: String - private let tiles: [(style: Style, url: String)] private let fileToucher: FileToucher private let tileHitRatioLock = NSLock() @@ -21,44 +12,10 @@ internal class StatsController { private let markerHitRatioLock = NSLock() private var markerHitRatios = [String: HitRatio]() - internal init(tileServerURL: String, tiles: [(style: Style, url: String)], fileToucher: FileToucher) { - self.tileServerURL = tileServerURL - self.tiles = tiles + internal init(fileToucher: FileToucher) { self.fileToucher = fileToucher } - // MARK: - Routes - - internal func get(request: Request) -> EventLoopFuture { - - self.tileHitRatioLock.lock() - let tileHitRatios = self.tileHitRatios.map { (ratio) -> [String: String] in - return ["key": "\(ratio.key)", "value": "\(ratio.value.displayValue)"] - } - self.tileHitRatioLock.unlock() - self.staticMapHitRatioLock.lock() - let staticMapHitRatios = self.staticMapHitRatios.map { (ratio) -> [String: String] in - return ["key": "\(ratio.key)", "value": "\(ratio.value.displayValue)"] - } - self.staticMapHitRatioLock.unlock() - self.markerHitRatioLock.lock() - let markerHitRatios = self.markerHitRatios.map { (ratio) -> [String: String] in - return ["key": "\(ratio.key)", "value": "\(ratio.value.displayValue)"] - } - self.markerHitRatioLock.unlock() - - let context = [ - "tileHitRatios": tileHitRatios, - "staticMapHitRatios": staticMapHitRatios, - "markerHitRatios": markerHitRatios - ] - return request.view.render("Resources/Views/Stats", context) - } - - internal func getStyles(request: Request) -> EventLoopFuture<[Style]> { - return loadStyles(request: request) - } - // MARK: - Stats internal func tileServed(new: Bool, path: String, style: String) { @@ -85,15 +42,25 @@ internal class StatsController { markerHitRatioLock.unlock() } - // MARK: - Utils + internal func getTileStats() -> [String : HitRatio] { + self.tileHitRatioLock.lock() + let tileHitRatios = self.tileHitRatios + self.tileHitRatioLock.unlock() + return tileHitRatios + } - private func loadStyles(request: Request) -> EventLoopFuture<[Style]> { - let stylesURL = "\(tileServerURL)/styles.json" - return APIUtils.loadJSON(request: request, from: stylesURL).flatMapError { error in - return request.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "Failed to load styles: (\(error.localizedDescription))")) - }.map({ (styles) -> ([Style]) in - return styles + self.tiles.map({$0.style}) - }) + internal func getStaticMapStats() -> [String : HitRatio] { + self.staticMapHitRatioLock.lock() + let staticMapHitRatios = self.staticMapHitRatios + self.staticMapHitRatioLock.unlock() + return staticMapHitRatios + } + + internal func getMarkerStats() -> [String : HitRatio] { + self.markerHitRatioLock.lock() + let markerHitRatios = self.markerHitRatios + self.markerHitRatioLock.unlock() + return markerHitRatios } } diff --git a/Sources/SwiftTileserverCache/Controller/StylesController.swift b/Sources/SwiftTileserverCache/Controller/StylesController.swift new file mode 100644 index 0000000..76d5c17 --- /dev/null +++ b/Sources/SwiftTileserverCache/Controller/StylesController.swift @@ -0,0 +1,325 @@ +import Vapor +import Leaf +import ZIPFoundation + +internal class StylesController { + + struct SaveStyleFiles: Content { + var id: String + var name: String + var styleJson: File + var spriteJson: File + var spriteImage: File + var spriteJson2x: File + var spriteImage2x: File + } + + struct SaveStyleZip: Content { + var id: String + var name: String + var zip: File + } + + private let tileServerURL: String + private var externalStyles: [String: Style] + private let folder: String + private let fontsController: FontsController + + internal init(tileServerURL: String, externalStyles: [Style], folder: String, fontsController: FontsController) { + try? FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(atPath: "\(folder)/External", withIntermediateDirectories: false) + self.tileServerURL = tileServerURL + self.externalStyles = (externalStyles + StylesController.loadExternalStyles(folder: folder)).reduce(into: [String: Style](), { (into, style) in + into[style.id] = style + }) + self.folder = folder + self.fontsController = fontsController + } + + // MARK: - Routes + + internal func get(request: Request) -> EventLoopFuture<[Style]> { + return loadLocalStyles(request: request).map { styles in + let externalStyles = Array(self.externalStyles.values) as [Style] + return (styles + externalStyles).map { $0.removingURL } + } + } + + internal func getWithAnalysis(request: Request) -> EventLoopFuture<[Style]> { + return get(request: request).flatMap({ styles in + let analysisFutures = styles.filter({$0.external != true}).map({ style in + return self.analyse(request: request, id: style.id).map({ analysis in + return (id: style.id, analysis: analysis) + }) + }) + return analysisFutures.flatten(on: request.eventLoop).map { analysis in + return styles.map({ style in + var newStyle = style + newStyle.analysis = analysis.first(where: {$0.id == style.id})?.analysis + return newStyle + }) + } + }) + } + + internal func analyse(request: Request, id: String) -> EventLoopFuture { + return analyseUsage(request: request, id: id).flatMap({ usage in + return self.analyseAvailableIcons(request: request, id: id).flatMapThrowing({ icons in + let fonts = try self.fontsController.getFonts() + let missingIcons = usage.icons.filter({!icons.contains($0)}) + let missingFonts = usage.fonts.filter({!fonts.contains($0)}) + return .init( + missingFonts: missingFonts, + missingIcons: missingIcons + ) + }) + }).recover({ _ in + return .init( + missingFonts: ["error loading style"], + missingIcons: ["error loading style"] + ) + }) + } + + internal func addExternal(request: Request) throws -> EventLoopFuture { + let style = try request.content.decode(Style.self) + guard style.external == true, style.url != nil else { + throw Abort(.badRequest, reason: "URL is required for external styles") + } + self.externalStyles[style.id] = style + return saveExternalStyles(request: request).map({.ok}) + } + + internal func deleteExternal(request: Request) throws -> EventLoopFuture { + guard let id = request.parameters.get("id") else { + throw Abort(.badRequest, reason: "ID parameter is required") + } + self.externalStyles[id] = nil + return saveExternalStyles(request: request).map({.ok}) + } + + internal func addLocal(request: Request) async throws -> HTTPStatus { + var name: String + var id: String + var styleJsonData: Data + var spriteJsonData: Data + var spriteImageData: Data + var spriteJson2xData: Data + var spriteImage2xData: Data + if var style = try? request.content.decode(SaveStyleZip.self) { + name = style.name + id = style.id + (styleJsonData, spriteJsonData, spriteImageData, spriteJson2xData, spriteImage2xData) = try await extractDataFromZip(request: request, style: &style) + } else if var style = try? request.content.decode(SaveStyleFiles.self) { + name = style.name + id = style.id + (styleJsonData, spriteJsonData, spriteImageData, spriteJson2xData, spriteImage2xData) = try extractDataFromFiles(style: &style) + } else { + throw Abort(.badRequest, reason: "Invalid request") + } + + guard var styleJson = try? JSONSerialization.jsonObject(with: styleJsonData) as? [String: Any] else { + throw Abort(.badRequest, reason: "style.json is not valid json") + } + styleJson["name"] = name + styleJson["sprite"] = "{styleJsonFolder}/\(id)/sprite" + styleJson["glyphs"] = "{fontstack}/{range}.pbf" + styleJson["sources"] = [ + "combined": [ + "type": "vector", + "url": "mbtiles://{combined}" + ] + ] + let layers = (styleJson["layers"] as? [[String: Any]] ?? []).map { (layer) -> [String: Any] in + var newLayer = layer + if newLayer["source"] as? String != nil { + newLayer["source"] = "combined" + } + if var layout = newLayer["layout"] as? [String: Any], let textFonts = layout["text-font"] as? [String] { + layout["text-font"] = textFonts.map({$0.toCamelCase}) + newLayer["layout"] = layout + } + + return newLayer + } + styleJson["layers"] = layers + + guard let modifiedStyleData = try? JSONSerialization.data(withJSONObject: styleJson, options: .prettyPrinted) else { + throw Abort(.internalServerError, reason: "failed to modify style.json") + } + + let stylePath = "\(folder)/\(id).json" + let spritePath = "\(folder)/\(id)" + try? FileManager.default.removeItem(atPath: stylePath) + try? FileManager.default.removeItem(atPath: spritePath) + try FileManager.default.createDirectory(atPath: spritePath, withIntermediateDirectories: false) + try await request.fileio.writeFile(ByteBuffer(data: modifiedStyleData), at: stylePath).get() + try await request.fileio.writeFile(ByteBuffer(data: spriteJsonData), at: "\(spritePath)/sprite.json").get() + try await request.fileio.writeFile(ByteBuffer(data: spriteImageData), at: "\(spritePath)/sprite.png").get() + try await request.fileio.writeFile(ByteBuffer(data: spriteJson2xData), at: "\(spritePath)/sprite@2x.json").get() + try await request.fileio.writeFile(ByteBuffer(data: spriteImage2xData), at: "\(spritePath)/sprite@2x.png").get() + return .ok + } + + + internal func extractFile(request: Request, archive: Archive, fileName: String) async throws -> Data { + guard let entry = archive[fileName] else { + throw Abort(.badRequest, reason: "\(fileName) is required") + } + let promise = request.eventLoop.makePromise(of: Data.self) + var data = Data() + _ = try archive.extract(entry, consumer: { bytes in + data.append(bytes) + if data.count >= entry.uncompressedSize { + promise.succeed(data) + } + }) + return try await promise.futureResult.get() + } + + internal func extractDataFromZip(request: Request, style: inout SaveStyleZip) async throws -> (styleJson: Data, spriteJson: Data, spriteImage: Data, spriteJson2x: Data, spriteImage2x: Data) { + guard let zipData = style.zip.data.readData(length: style.zip.data.readableBytes), let archive = Archive(data: zipData, accessMode: .read) else { + throw Abort(.badRequest, reason: "zip is invalid") + } + let styleJsonData = try await extractFile(request: request, archive: archive, fileName: "style.json") + let spriteJsonData = try await extractFile(request: request, archive: archive, fileName: "sprite.json") + let spriteImageData = try await extractFile(request: request, archive: archive, fileName: "sprite.png") + let spriteJson2xData = try await extractFile(request: request, archive: archive, fileName: "sprite@2x.json") + let spriteImage2xData = try await extractFile(request: request, archive: archive, fileName: "sprite@2x.png") + return (styleJsonData, spriteJsonData, spriteImageData, spriteJson2xData, spriteImage2xData) + } + + internal func extractDataFromFiles(style: inout SaveStyleFiles) throws -> (styleJson: Data, spriteJson: Data, spriteImage: Data, spriteJson2x: Data, spriteImage2x: Data) { + guard let styleJsonData = style.styleJson.data.readData(length: style.styleJson.data.readableBytes) else { + throw Abort(.badRequest, reason: "style.json is required") + } + guard let spriteJsonData = style.spriteJson.data.readData(length: style.spriteJson.data.readableBytes) else { + throw Abort(.badRequest, reason: "sprite.json is required") + } + guard let spriteImageData = style.spriteImage.data.readData(length: style.spriteImage.data.readableBytes) else { + throw Abort(.badRequest, reason: "sprite.png is required") + } + guard let spriteJson2xData = style.spriteJson2x.data.readData(length: style.spriteJson2x.data.readableBytes) else { + throw Abort(.badRequest, reason: "sprite@2x.json is required") + } + guard let spriteImage2xData = style.spriteImage2x.data.readData(length: style.spriteImage2x.data.readableBytes) else { + throw Abort(.badRequest, reason: "sprite@2x.png is required") + } + return (styleJsonData, spriteJsonData, spriteImageData, spriteJson2xData, spriteImage2xData) + } + + internal func deleteLocal(request: Request) throws -> EventLoopFuture { + guard let id = request.parameters.get("id") else { + throw Abort(.badRequest, reason: "ID parameter is required") + } + let stylePath = "\(folder)/\(id).json" + let spritePath = "\(folder)/\(id)" + try? FileManager.default.removeItem(atPath: stylePath) + try? FileManager.default.removeItem(atPath: spritePath) + return request.eventLoop.future(.ok) + } + + // MARK: - Utils + + internal func getExternalStyle(name: String) -> Style? { + return externalStyles[name] + } + + private func loadLocalStyles(request: Request) -> EventLoopFuture<[Style]> { + let stylesURL = "\(tileServerURL)/styles.json" + return APIUtils.loadJSON(request: request, from: stylesURL).flatMapError { error in + return request.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "Failed to load styles: (\(error.localizedDescription))")) + } + } + + private static func loadExternalStyles(folder: String) -> [Style] { + let stylesFile = "\(folder)/External/styles.json" + guard let data = FileManager.default.contents(atPath: stylesFile) else { + return [] + } + do { + return try JSONDecoder().decode([Style].self, from: data) + } catch { + return [] + } + } + + private func saveExternalStyles(request: Request) -> EventLoopFuture { + let stylesFile = "\(folder)/External/styles.json" + try? FileManager.default.removeItem(atPath: stylesFile) + let byteBuffer: ByteBuffer + do { + byteBuffer = try JSONEncoder().encodeAsByteBuffer(Array(self.externalStyles.values) as [Style], allocator: .init()) + } catch { + return request.eventLoop.makeFailedFuture(error) + } + return request.fileio.writeFile(byteBuffer, at: stylesFile) + } + + private func analyseUsage(request: Request, id: String) -> EventLoopFuture<(fonts: [String], icons: [String])> { + return request.application.fileio.openFile( + path: "\(folder)/\(id).json", + mode: .read, + eventLoop: request.eventLoop + ).flatMap { fileHandle in + return request.application.fileio.read(fileHandle: fileHandle, byteCount: 131_072, allocator: .init(), eventLoop: request.eventLoop).flatMapThrowing { buffer in + guard let styleJson = try? JSONSerialization.jsonObject(with: Data(buffer: buffer)) as? [String: Any], + let layers = styleJson["layers"] as? [[String: Any]] + else { + throw Abort(.badRequest, reason: "style.json is not valid josn") + } + var fonts = Set() + var icons = Set() + + // TODO: Implement Resolved Icons + for layer in layers { + if let layout = layer["layout"] as? [String: Any] { + if let textFonts = layout["text-font"] as? [String], !textFonts.isEmpty { + fonts.insert(textFonts[0]) + } + if let iconImage = layout["icon-image"] as? String, !iconImage.isEmpty { + icons.insert(iconImage) + } + } + if let paint = layer["paint"] as? [String: Any] { + if let backgroundPattern = paint["background-pattern"] as? String, !backgroundPattern.isEmpty { + icons.insert(backgroundPattern) + } + if let fillPattern = paint["fill-pattern"] as? String, !fillPattern.isEmpty { + icons.insert(fillPattern) + } + if let linePattern = paint["line-pattern"] as? String, !linePattern.isEmpty { + icons.insert(linePattern) + } + if let fillExtrusionPattern = paint["fill-extrusion-pattern"] as? String, !fillExtrusionPattern.isEmpty { + icons.insert(fillExtrusionPattern) + } + } + + } + return (fonts: Array(fonts), icons: Array(icons)) + }.always { _ in + try? fileHandle.close() + } + } + } + + private func analyseAvailableIcons(request: Request, id: String) -> EventLoopFuture<[String]> { + return request.application.fileio.openFile( + path: "\(folder)/\(id)/sprite.json", + mode: .read, + eventLoop: request.eventLoop + ).flatMap { fileHandle in + return request.application.fileio.read(fileHandle: fileHandle, byteCount: 131_072, allocator: .init(), eventLoop: request.eventLoop).flatMapThrowing { buffer in + guard let iconsJson = try? JSONSerialization.jsonObject(with: Data(buffer: buffer)) as? [String: Any] else { + throw Abort(.badRequest, reason: "sprite.json is not valid josn") + } + return Array(iconsJson.keys) + }.always { _ in + try? fileHandle.close() + } + } + } + + +} diff --git a/Sources/SwiftTileserverCache/Controller/TemplatesController.swift b/Sources/SwiftTileserverCache/Controller/TemplatesController.swift new file mode 100644 index 0000000..8e94f69 --- /dev/null +++ b/Sources/SwiftTileserverCache/Controller/TemplatesController.swift @@ -0,0 +1,94 @@ +import Vapor +import Leaf + +internal class TemplatesController { + + struct PreviewTemplate: Decodable { + enum Mode: String, Decodable { + case staticMap = "StaticMap" + case multiStaticMap = "MultiStaticMap" + } + var template: String + var context: [String: LeafData] + var mode: Mode + } + + struct SaveTemplate: Codable { + var template: String + var name: String + var oldName: String + } + + private let folder: String + private let staticMapController: StaticMapController + private let multiStaticMapController: MultiStaticMapController + + internal init(folder: String, staticMapController: StaticMapController, multiStaticMapController: MultiStaticMapController) { + self.folder = folder + self.staticMapController = staticMapController + self.multiStaticMapController = multiStaticMapController + try? FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + } + + + // MARK: - Routes + + internal func preview(request: Request) throws -> EventLoopFuture { + let template = try request.content.decode(PreviewTemplate.self) + + let tempFilename = "\(folder)/preview-\(UUID().uuidString).json" + return request.fileio.writeFile(ByteBuffer(string: template.template), at: tempFilename).flatMap { + return request.leaf.render(path: tempFilename, context: template.context).flatMap({ buffer in + var bufferVar = buffer + do { + if template.mode == .staticMap, let staticMap = try bufferVar.readJSONDecodable(StaticMap.self, length: buffer.readableBytes) { + return self.staticMapController.handleRequest(request: request, staticMap: staticMap) + } else if template.mode == .multiStaticMap, let multiStaticMap = try bufferVar.readJSONDecodable(MultiStaticMap.self, length: buffer.readableBytes) { + return self.multiStaticMapController.handleRequest(request: request, multiStaticMap: multiStaticMap) + } else { + return request.eventLoop.future(error: Abort(.badRequest, reason: "Invalid Template")) + } + } catch let error as DecodingError { + return request.eventLoop.future(error: Abort(.badRequest, reason: error.description)) + } catch { + return request.eventLoop.future(error: Abort(.badRequest, reason: error.localizedDescription)) + } + }).always { _ in + try? FileManager.default.removeItem(atPath: tempFilename) + } + } + } + + internal func save(request: Request) throws -> EventLoopFuture { + let template = try request.content.decode(SaveTemplate.self) + let fileName = "\(folder)/\(template.name).json" + try? FileManager.default.removeItem(atPath: fileName) + if template.name != template.oldName { + try? FileManager.default.removeItem(atPath: "\(self.folder)/\(template.oldName).json") + } + return request.fileio.writeFile(ByteBuffer(string: template.template), at: fileName).map { _ in + + return Response(status: .ok) + } + } + + internal func delete(request: Request) throws -> Response { + try FileManager.default.removeItem(atPath: "\(folder)/\(request.parameters.get("name") ?? "").json") + return Response(status: .ok) + } + + // MARK: - Utils + + func getTemplates() throws -> [String] { + return try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: folder), includingPropertiesForKeys: nil) + .filter({ $0.pathExtension == "json" }) + .map({ $0.deletingPathExtension().lastPathComponent }) + } + + func getTemplateContent(name: String) -> String? { + let data = FileManager.default.contents(atPath: folder + "/" + name + ".json") + return (data != nil) ? String(data: data!, encoding: .utf8) : nil + } + +} + diff --git a/Sources/SwiftTileserverCache/Controller/TileController.swift b/Sources/SwiftTileserverCache/Controller/TileController.swift index cf731cb..a09e6f6 100644 --- a/Sources/SwiftTileserverCache/Controller/TileController.swift +++ b/Sources/SwiftTileserverCache/Controller/TileController.swift @@ -1,22 +1,15 @@ -// -// StaticMapController.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 08.05.20. -// - import Vapor internal class TileController { private let tileServerURL: String private let statsController: StatsController - private let tiles: [String: String] + private let stylesController: StylesController - internal init(tileServerURL: String, tiles: [(style: Style, url: String)], statsController: StatsController) { + internal init(tileServerURL: String, statsController: StatsController, stylesController: StylesController) { self.tileServerURL = tileServerURL - self.tiles = tiles.reduce(into: [String: String](), { $0[$1.style.id] = $1.url }) self.statsController = statsController + self.stylesController = stylesController } // MARK: - Routes @@ -48,7 +41,7 @@ internal class TileController { let scaleString = scale == 1 ? "" : "@\(scale)x" let tileURL: String - if let url = tiles[style] { + if let url = stylesController.getExternalStyle(name: style)?.url { tileURL = url.replacingOccurrences(of: "{z}", with: "\(z)") .replacingOccurrences(of: "{x}", with: "\(x)") .replacingOccurrences(of: "{y}", with: "\(y)") @@ -59,7 +52,7 @@ internal class TileController { tileURL = "\(tileServerURL)/styles/\(style)/\(z)/\(x)/\(y)\(scaleString).\(format)" } return APIUtils.downloadFile(request: request, from: tileURL, to: path, type: "image").flatMapError { error in - return request.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "Failed to load tile (\(error.localizedDescription))")) + return request.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "Failed to load tile: \(tileURL) (\(error.localizedDescription))")) }.always { result in if case .success = result { request.application.logger.info("Served a generated tile") diff --git a/Sources/SwiftTileserverCache/LeafTag/FormatTag.swift b/Sources/SwiftTileserverCache/LeafTag/FormatTag.swift index 132eeaa..da7b290 100644 --- a/Sources/SwiftTileserverCache/LeafTag/FormatTag.swift +++ b/Sources/SwiftTileserverCache/LeafTag/FormatTag.swift @@ -1,10 +1,3 @@ -// -// FormatTag.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 12.05.20. -// - import Vapor import Leaf @@ -15,7 +8,7 @@ class FormatTag: LeafTag { let anyArgument = ctx.parameters.first, let format = ctx.parameters.last?.string, let argument: CVarArg = (anyArgument.int ?? anyArgument.double) else { - throw "format tag rquires exactly 2 Argument: (argument: Int|Double, format: String)" + throw Abort(.badRequest, reason: "format tag rquires exactly 2 Argument: (argument: Int|Double, format: String)") } return LeafData.string(String(format: format, argument)) } diff --git a/Sources/SwiftTileserverCache/LeafTag/IndexTag.swift b/Sources/SwiftTileserverCache/LeafTag/IndexTag.swift index c66441e..3e51674 100644 --- a/Sources/SwiftTileserverCache/LeafTag/IndexTag.swift +++ b/Sources/SwiftTileserverCache/LeafTag/IndexTag.swift @@ -1,10 +1,3 @@ -// -// FormatTag.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 12.05.20. -// - import Vapor import Leaf @@ -14,7 +7,7 @@ class IndexTag: LeafTag { guard ctx.parameters.count == 2, let array = ctx.parameters.first?.array, let index = ctx.parameters.last?.int else { - throw "format tag rquires exactly 2 Argument: (array: [LeafData], index: int)" + throw Abort(.badRequest, reason: "index tag rquires exactly 2 Argument: (array: [LeafData], index: int)") } return array[index] } diff --git a/Sources/SwiftTileserverCache/LeafTag/PadTag.swift b/Sources/SwiftTileserverCache/LeafTag/PadTag.swift index 50eac75..24d3f6f 100644 --- a/Sources/SwiftTileserverCache/LeafTag/PadTag.swift +++ b/Sources/SwiftTileserverCache/LeafTag/PadTag.swift @@ -1,10 +1,3 @@ -// -// PadTag.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 12.05.20. -// - import Vapor import Leaf @@ -14,7 +7,7 @@ class PadTag: LeafTag { guard ctx.parameters.count == 2, let number = ctx.parameters.first?.int, let count = ctx.parameters.last?.int else { - throw "format tag rquires exactly 2 Argument: (number: Int, zeros: Int)" + throw Abort(.badRequest, reason: "pad tag rquires exactly 2 Argument: (number: Int, zeros: Int)") } return LeafData.string(String(format: "%0\(count)d", number)) } diff --git a/Sources/SwiftTileserverCache/LeafTag/RoundTag.swift b/Sources/SwiftTileserverCache/LeafTag/RoundTag.swift index ba58a3a..f63c6ee 100644 --- a/Sources/SwiftTileserverCache/LeafTag/RoundTag.swift +++ b/Sources/SwiftTileserverCache/LeafTag/RoundTag.swift @@ -1,10 +1,3 @@ -// -// RoundTag.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 12.05.20. -// - import Vapor import Leaf @@ -14,7 +7,7 @@ class RoundTag: LeafTag { guard ctx.parameters.count == 2, let number = ctx.parameters.first?.double, let count = ctx.parameters.last?.int else { - throw "format tag rquires exactly 2 Argument: (number: Double, decimals: Int)" + throw Abort(.badRequest, reason: "round tag rquires exactly 2 Argument: (number: Double, decimals: Int)") } return LeafData.string(String(format: "%.\(count)f", number)) } diff --git a/Sources/SwiftTileserverCache/Misc/APIUtils.swift b/Sources/SwiftTileserverCache/Misc/APIUtils.swift index 43ee2ea..3811db1 100644 --- a/Sources/SwiftTileserverCache/Misc/APIUtils.swift +++ b/Sources/SwiftTileserverCache/Misc/APIUtils.swift @@ -1,10 +1,3 @@ -// -// APIUtils.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 03.03.20. -// - import Foundation import Vapor @@ -18,7 +11,7 @@ public class APIUtils { let errorReason: String if response.status.code >= 200 && response.status.code < 300 { if let type = type, response.content.contentType?.type != type { - errorReason = "Failed to load file. Got invalid type: \(request.content.contentType?.description ?? "non")" + errorReason = "Failed to load file. Got invalid type: \(request.content.contentType?.description ?? "none")" } else if let body = response.body, body.readableBytes != 0 { return request.application.fileio.openFile( path: to, diff --git a/Sources/SwiftTileserverCache/Misc/AdminAuthenticator.swift b/Sources/SwiftTileserverCache/Misc/AdminAuthenticator.swift new file mode 100644 index 0000000..4fec461 --- /dev/null +++ b/Sources/SwiftTileserverCache/Misc/AdminAuthenticator.swift @@ -0,0 +1,32 @@ +import Vapor + +struct AdminAuthenticator: Middleware { + + let username: String + let password: String + + init() { + self.username = Environment.get("ADMIN_USERNAME")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.password = Environment.get("ADMIN_PASSWORD")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { + guard username != "", password != "" else { + return request.eventLoop.makeFailedFuture(Abort(.unauthorized, reason: "Dashboard Disabled!")) + } + if request.headers.basicAuthorization == nil, + let sessionUsername = request.session.data["basicAuthorization.username"], + let sessionPassword = request.session.data["basicAuthorization.password"] { + request.headers.basicAuthorization = BasicAuthorization(username: sessionUsername, password: sessionPassword) + } + guard let basicAuthorization = request.headers.basicAuthorization else { + return request.eventLoop.makeFailedFuture(Abort(.unauthorized, headers: HTTPHeaders([("WWW-Authenticate","Basic")]), reason: "Login Required!")) + } + request.session.data["basicAuthorization.username"] = basicAuthorization.username + request.session.data["basicAuthorization.password"] = basicAuthorization.password + guard username == basicAuthorization.username && password == basicAuthorization.password else { + return request.eventLoop.makeFailedFuture(Abort(.unauthorized, headers: HTTPHeaders([("WWW-Authenticate","Basic")]), reason: "Invalid Login!")) + } + return next.respond(to: request) + } +} diff --git a/Sources/SwiftTileserverCache/Misc/CacheCleaner.swift b/Sources/SwiftTileserverCache/Misc/CacheCleaner.swift index 12901e7..78695f5 100644 --- a/Sources/SwiftTileserverCache/Misc/CacheCleaner.swift +++ b/Sources/SwiftTileserverCache/Misc/CacheCleaner.swift @@ -1,10 +1,3 @@ -// -// CacheCleaner.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 02.11.19. -// - import Foundation import Vapor @@ -14,15 +7,19 @@ public class CacheCleaner { private let folder: URL private let maxAgeMinutes: UInt32 - public init(folder: String, maxAgeMinutes: UInt32, clearDelaySeconds: UInt32=60) { + public init(folder: String, maxAgeMinutes: UInt32?, clearDelaySeconds: UInt32?) { self.folder = URL(fileURLWithPath: folder) - self.maxAgeMinutes = maxAgeMinutes + self.maxAgeMinutes = maxAgeMinutes ?? 0 self.logger = Logger(label: "CacheCleaner-\(folder)") let thread = DispatchQueue(label: "CacheCleaner-\(folder)") - thread.async { - while true { - self.runOnce() - sleep(clearDelaySeconds) + try? FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + if maxAgeMinutes != nil && clearDelaySeconds != nil { + self.logger.notice("Starting CacheCleaner for \(folder) with maxAgeMinutes: \(maxAgeMinutes!) and clearDelaySeconds: \(clearDelaySeconds!)") + thread.async { + while true { + self.runOnce() + sleep(clearDelaySeconds!) + } } } } diff --git a/Sources/SwiftTileserverCache/Misc/FileToucher.swift b/Sources/SwiftTileserverCache/Misc/FileToucher.swift index 3dacc14..aa9e26c 100644 --- a/Sources/SwiftTileserverCache/Misc/FileToucher.swift +++ b/Sources/SwiftTileserverCache/Misc/FileToucher.swift @@ -1,10 +1,3 @@ -// -// FileToucher.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 06.05.20. -// - import Foundation import Vapor diff --git a/Sources/SwiftTileserverCache/Misc/ImageUtils.swift b/Sources/SwiftTileserverCache/Misc/ImageUtils.swift index 4e90f11..6216c72 100644 --- a/Sources/SwiftTileserverCache/Misc/ImageUtils.swift +++ b/Sources/SwiftTileserverCache/Misc/ImageUtils.swift @@ -1,10 +1,3 @@ -// -// ImageUtils.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 03.03.20. -// - import Foundation import Vapor import ShellOut @@ -80,9 +73,11 @@ public class ImageUtils { try escapedShellOut(to: ImageUtils.imagemagickConvertCommand, arguments: args) } catch let error as ShellOutError { request.application.logger.error("Failed to run magick: \(error.message)") + removeImages(request: request, paths: tilePaths + [path]) throw Abort(.internalServerError, reason: "ImageMagick Error: \(error.message)") } catch { request.application.logger.error("Failed to run magick: \(error)") + removeImages(request: request, paths: tilePaths + [path]) throw Abort(.internalServerError, reason: "ImageMagick Error") } } @@ -158,6 +153,7 @@ public class ImageUtils { } var markerArguments = [String]() + var markerPaths = [String]() for marker in staticMap.markers ?? [] { let realOffset = getRealOffset( at: Coordinate(latitude: marker.latitude, longitude: marker.longitude), @@ -196,7 +192,7 @@ public class ImageUtils { markerPath = "Markers/\(marker.url)" } if let fallbackUrl = marker.fallbackUrl, !FileManager.default.fileExists(atPath: markerPath) { - if fallbackUrl.starts(with: "http://") || marker.url.starts(with: "https://") { + if fallbackUrl.starts(with: "http://") || fallbackUrl.starts(with: "https://") { let markerHashed = fallbackUrl.persistentHash let markerFormat = fallbackUrl.components(separatedBy: ".").last ?? "png" markerPath = "Cache/Marker/\(markerHashed).\(markerFormat)" @@ -205,6 +201,7 @@ public class ImageUtils { } } + markerPaths.append(markerPath) markerArguments += [ "(", markerPath, "-resize", "\(marker.width * UInt16(staticMap.scale))x\(marker.height * UInt16(staticMap.scale))", ")", "-gravity", "Center", @@ -225,9 +222,11 @@ public class ImageUtils { try escapedShellOut(to: ImageUtils.imagemagickConvertCommand, arguments: args) } catch let error as ShellOutError { request.application.logger.error("Failed to run magick: \(error.message)") + removeImages(request: request, paths: [basePath, path] + markerPaths) throw Abort(.internalServerError, reason: "ImageMagick Error: \(error.message)") } catch { request.application.logger.error("Failed to run magick: \(error)") + removeImages(request: request, paths: [basePath, path] + markerPaths) throw Abort(.internalServerError, reason: "ImageMagick Error") } } @@ -236,6 +235,7 @@ public class ImageUtils { public static func generateMultiStaticMap(request: Request, multiStaticMap: MultiStaticMap, path: String) -> EventLoopFuture { var grids = [(firstPath: String, direction: CombineDirection, images: [(direction: CombineDirection, path: String)])]() + var mapPaths = [String]() for grid in multiStaticMap.grid { var firstMapUrl = "" var images = [(CombineDirection, String)]() @@ -246,6 +246,7 @@ public class ImageUtils { } else { images.append((map.direction, url)) } + mapPaths.append(url) } grids.append((firstMapUrl, grid.direction, images)) } @@ -276,9 +277,11 @@ public class ImageUtils { try escapedShellOut(to: imagemagickConvertCommand, arguments: args) } catch let error as ShellOutError { request.application.logger.error("Failed to run magick: \(error.message)") + removeImages(request: request, paths: [path] + mapPaths) throw Abort(.internalServerError, reason: "ImageMagick Error: \(error.message)") } catch { request.application.logger.error("Failed to run magick: \(error)") + removeImages(request: request, paths: [path] + mapPaths) throw Abort(.internalServerError, reason: "ImageMagick Error") } } @@ -306,4 +309,9 @@ public class ImageUtils { return (realOffsetX + (Int(extraX) * Int(scale)), realOffsetY + (Int(extraY) * Int(scale))) } + private static func removeImages(request: Request, paths: [String]) -> Void { + request.logger.info("Clearing \(paths.count) potentially broken images") + try? escapedShellOut(to: "rm", arguments: ["-f"] + paths) + } + } diff --git a/Sources/SwiftTileserverCache/Misc/LeafCacheCleaner.swift b/Sources/SwiftTileserverCache/Misc/LeafCacheCleaner.swift index faf7ada..05b4de8 100644 --- a/Sources/SwiftTileserverCache/Misc/LeafCacheCleaner.swift +++ b/Sources/SwiftTileserverCache/Misc/LeafCacheCleaner.swift @@ -1,10 +1,3 @@ -// -// LeafCacheCleaner.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 02.11.19. -// - import Foundation import Vapor diff --git a/Sources/SwiftTileserverCache/Misc/LeafData+Decodable.swift b/Sources/SwiftTileserverCache/Misc/LeafData+Decodable.swift index 7be1ca5..65ac669 100644 --- a/Sources/SwiftTileserverCache/Misc/LeafData+Decodable.swift +++ b/Sources/SwiftTileserverCache/Misc/LeafData+Decodable.swift @@ -1,10 +1,3 @@ -// -// LeafData+Decodable.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 11.05.20. -// - import Foundation import Vapor import Leaf diff --git a/Sources/SwiftTileserverCache/Misc/ResponseUtils.swift b/Sources/SwiftTileserverCache/Misc/ResponseUtils.swift index 29a1d18..083b8f8 100644 --- a/Sources/SwiftTileserverCache/Misc/ResponseUtils.swift +++ b/Sources/SwiftTileserverCache/Misc/ResponseUtils.swift @@ -1,10 +1,3 @@ -// -// ResponseUtils.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 17.05.20. -// - import Vapor internal class ResponseUtils where T: Codable { diff --git a/Sources/SwiftTileserverCache/Misc/SphericalMercator.swift b/Sources/SwiftTileserverCache/Misc/SphericalMercator.swift index 4a9fdab..d9a0ef5 100644 --- a/Sources/SwiftTileserverCache/Misc/SphericalMercator.swift +++ b/Sources/SwiftTileserverCache/Misc/SphericalMercator.swift @@ -1,12 +1,6 @@ -// -// SphericalMercator.swift -// SwiftTileserverCache -// -// From: https://github.com/qin9smile/sphericalmercator.swift/blob/master/sphericalmercator.swift -// - import Foundation +// Source: https://github.com/qin9smile/sphericalmercator.swift/blob/master/sphericalmercator.swift public struct Coordinate { public var latitude: Double public var longitude: Double diff --git a/Sources/SwiftTileserverCache/Misc/String+BashEscaped.swift b/Sources/SwiftTileserverCache/Misc/String+BashEscaped.swift index 59e66cc..e038603 100644 --- a/Sources/SwiftTileserverCache/Misc/String+BashEscaped.swift +++ b/Sources/SwiftTileserverCache/Misc/String+BashEscaped.swift @@ -1,10 +1,3 @@ -// -// String+BashEncode.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 17.05.20. -// - import Foundation internal extension String { diff --git a/Sources/SwiftTileserverCache/Misc/String+CamelCase.swift b/Sources/SwiftTileserverCache/Misc/String+CamelCase.swift new file mode 100644 index 0000000..8e1d801 --- /dev/null +++ b/Sources/SwiftTileserverCache/Misc/String+CamelCase.swift @@ -0,0 +1,13 @@ +import Foundation + +internal extension String { + var toCamelCase: String { + return self.components(separatedBy: ["_", "-", " ", "."]) + .map({ + $0.replacingCharacters( + in: ...$0.startIndex, + with: $0.first?.uppercased() ?? "" + ) + }).joined() + } +} diff --git a/Sources/SwiftTileserverCache/Misc/String+IsEmpty.swift b/Sources/SwiftTileserverCache/Misc/String+IsEmpty.swift new file mode 100644 index 0000000..8d5c9f1 --- /dev/null +++ b/Sources/SwiftTileserverCache/Misc/String+IsEmpty.swift @@ -0,0 +1,7 @@ +import Foundation + +internal extension String { + var isEmpty: Bool { + return self.trimmingCharacters(in: .whitespaces) == "" + } +} diff --git a/Sources/SwiftTileserverCache/Misc/StringArray+BashEscaped.swift b/Sources/SwiftTileserverCache/Misc/StringArray+BashEscaped.swift index 001705e..91b45de 100644 --- a/Sources/SwiftTileserverCache/Misc/StringArray+BashEscaped.swift +++ b/Sources/SwiftTileserverCache/Misc/StringArray+BashEscaped.swift @@ -1,10 +1,3 @@ -// -// String+BashEncode.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 17.05.20. -// - import Foundation internal extension Array where Element == String { diff --git a/Sources/SwiftTileserverCache/Model/Circle.swift b/Sources/SwiftTileserverCache/Model/Circle.swift index 0106f8c..39cb2ec 100644 --- a/Sources/SwiftTileserverCache/Model/Circle.swift +++ b/Sources/SwiftTileserverCache/Model/Circle.swift @@ -1,10 +1,3 @@ -// -// Circle.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 14.09.20. -// - import Foundation public struct Circle: Codable, Hashable, Drawable { diff --git a/Sources/SwiftTileserverCache/Model/CombineDirection.swift b/Sources/SwiftTileserverCache/Model/CombineDirection.swift index fb5d86a..1cb34bd 100644 --- a/Sources/SwiftTileserverCache/Model/CombineDirection.swift +++ b/Sources/SwiftTileserverCache/Model/CombineDirection.swift @@ -1,10 +1,3 @@ -// -// CombineDirection.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 04.03.20. -// - import Foundation public enum CombineDirection: String, Codable, Hashable { diff --git a/Sources/SwiftTileserverCache/Model/Drawable.swift b/Sources/SwiftTileserverCache/Model/Drawable.swift index a349f3a..fe819f5 100644 --- a/Sources/SwiftTileserverCache/Model/Drawable.swift +++ b/Sources/SwiftTileserverCache/Model/Drawable.swift @@ -1,10 +1,3 @@ -// -// Drawable.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 05.11.19. -// - import Foundation public protocol Drawable {} diff --git a/Sources/SwiftTileserverCache/Model/HitRatio.swift b/Sources/SwiftTileserverCache/Model/HitRatio.swift index 93648aa..f00d304 100644 --- a/Sources/SwiftTileserverCache/Model/HitRatio.swift +++ b/Sources/SwiftTileserverCache/Model/HitRatio.swift @@ -1,10 +1,3 @@ -// -// HitRatio.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 09.05.20. -// - import Foundation public struct HitRatio: Codable { diff --git a/Sources/SwiftTileserverCache/Model/ImageFormat.swift b/Sources/SwiftTileserverCache/Model/ImageFormat.swift index 16456e3..824cfa1 100644 --- a/Sources/SwiftTileserverCache/Model/ImageFormat.swift +++ b/Sources/SwiftTileserverCache/Model/ImageFormat.swift @@ -1,10 +1,3 @@ -// -// CombineDirection.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 04.03.20. -// - import Foundation public enum ImageFormat: String, Codable, Hashable { diff --git a/Sources/SwiftTileserverCache/Model/Marker.swift b/Sources/SwiftTileserverCache/Model/Marker.swift index e28a102..5facd0e 100644 --- a/Sources/SwiftTileserverCache/Model/Marker.swift +++ b/Sources/SwiftTileserverCache/Model/Marker.swift @@ -1,10 +1,3 @@ -// -// Marker.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 01.11.19. -// - import Foundation public struct Marker: Codable, Hashable, Drawable { diff --git a/Sources/SwiftTileserverCache/Model/MultiStaticMap.swift b/Sources/SwiftTileserverCache/Model/MultiStaticMap.swift index e732862..b57dee6 100644 --- a/Sources/SwiftTileserverCache/Model/MultiStaticMap.swift +++ b/Sources/SwiftTileserverCache/Model/MultiStaticMap.swift @@ -1,10 +1,3 @@ -// -// MultiStaticMap.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 04.03.20. -// - import Foundation public struct MultiStaticMap: Codable, Hashable, PersistentHashable { diff --git a/Sources/SwiftTileserverCache/Model/PersistentHashable.swift b/Sources/SwiftTileserverCache/Model/PersistentHashable.swift index f67ce64..a0c328e 100644 --- a/Sources/SwiftTileserverCache/Model/PersistentHashable.swift +++ b/Sources/SwiftTileserverCache/Model/PersistentHashable.swift @@ -1,10 +1,3 @@ -// -// PersistentHashable.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 04.03.20. -// - import Foundation import Vapor diff --git a/Sources/SwiftTileserverCache/Model/Polygon.swift b/Sources/SwiftTileserverCache/Model/Polygon.swift index 528c757..adea2c8 100644 --- a/Sources/SwiftTileserverCache/Model/Polygon.swift +++ b/Sources/SwiftTileserverCache/Model/Polygon.swift @@ -1,10 +1,3 @@ -// -// Polygon.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 05.11.19. -// - import Foundation public struct Polygon: Codable, Hashable, Drawable { diff --git a/Sources/SwiftTileserverCache/Model/StaticMap.swift b/Sources/SwiftTileserverCache/Model/StaticMap.swift index 6cd8853..69078ee 100644 --- a/Sources/SwiftTileserverCache/Model/StaticMap.swift +++ b/Sources/SwiftTileserverCache/Model/StaticMap.swift @@ -1,10 +1,3 @@ -// -// StaticMap.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 04.03.20. -// - import Foundation public struct StaticMap: Codable, Hashable, PersistentHashable { diff --git a/Sources/SwiftTileserverCache/Model/Style.swift b/Sources/SwiftTileserverCache/Model/Style.swift index 2d7e908..806c07b 100644 --- a/Sources/SwiftTileserverCache/Model/Style.swift +++ b/Sources/SwiftTileserverCache/Model/Style.swift @@ -1,13 +1,19 @@ -// -// Style.swift -// SwiftTileserverCache -// -// Created by Florian Kostenzer on 03.03.20. -// - import Vapor public struct Style: Content { + + public struct Analysis: Codable { + var missingFonts: [String] + var missingIcons: [String] + } + public var id: String public var name: String + public var external: Bool? + public var url: String? + public var analysis: Analysis? + + public var removingURL: Style { + return Style(id: id, name: name, external: external, url: nil) + } } diff --git a/Sources/SwiftTileserverCache/ViewController/DatasetsAddViewController.swift b/Sources/SwiftTileserverCache/ViewController/DatasetsAddViewController.swift new file mode 100644 index 0000000..285c7e1 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/DatasetsAddViewController.swift @@ -0,0 +1,22 @@ +import Foundation +import Vapor +import Leaf + +internal class DatasetsAddViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + } + + init() {} + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "datasets", + pageName: "Add Dataset" + ) + return self.render(request: request, template: "DatasetsAdd", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/DatasetsDeleteController.swift b/Sources/SwiftTileserverCache/ViewController/DatasetsDeleteController.swift new file mode 100644 index 0000000..3023b13 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/DatasetsDeleteController.swift @@ -0,0 +1,24 @@ +import Foundation +import Vapor +import Leaf + +internal class DatasetsDeleteViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var datasetName: String + } + + init() {} + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "datasets", + pageName: "Delete Datase", + datasetName: request.parameters.get("name") ?? "" + ) + return self.render(request: request, template: "DatasetsDelete", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/DatasetsViewController.swift b/Sources/SwiftTileserverCache/ViewController/DatasetsViewController.swift new file mode 100644 index 0000000..d1943fb --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/DatasetsViewController.swift @@ -0,0 +1,34 @@ +import Foundation +import Vapor +import Leaf + +internal class DatasetsViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var datasets: [String] + } + + let datasetsController: DatasetsController + + init(datasetsController: DatasetsController) { + self.datasetsController = datasetsController + } + + internal func render(request: Request) throws -> EventLoopFuture { + let datasets: [String] + do { + datasets = try datasetsController.getDatasets() + } catch { + throw Abort(.internalServerError, reason: "Failed to get datasets: (\(error.localizedDescription))") + } + let context = Context( + pageId: "datasets", + pageName: "Datasets", + datasets: datasets + ) + return self.render(request: request, template: "Datasets", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/FontsAddViewController.swift b/Sources/SwiftTileserverCache/ViewController/FontsAddViewController.swift new file mode 100644 index 0000000..f1be21e --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/FontsAddViewController.swift @@ -0,0 +1,22 @@ +import Foundation +import Vapor +import Leaf + +internal class FontsAddViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + } + + init() {} + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "fonts", + pageName: "Add Fonts" + ) + return self.render(request: request, template: "FontsAdd", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/FontsViewController.swift b/Sources/SwiftTileserverCache/ViewController/FontsViewController.swift new file mode 100644 index 0000000..80c1368 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/FontsViewController.swift @@ -0,0 +1,34 @@ +import Foundation +import Vapor +import Leaf + +internal class FontsViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var fonts: [String] + } + + let fontsController: FontsController + + init(fontsController: FontsController) { + self.fontsController = fontsController + } + + internal func render(request: Request) throws -> EventLoopFuture { + let fonts: [String] + do { + fonts = try fontsController.getFonts() + } catch { + throw Abort(.internalServerError, reason: "Failed to get fonts: (\(error.localizedDescription))") + } + let context = Context( + pageId: "fonts", + pageName: "Fonts", + fonts: fonts + ) + return self.render(request: request, template: "Fonts", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/StatsViewController.swift b/Sources/SwiftTileserverCache/ViewController/StatsViewController.swift new file mode 100644 index 0000000..66d6605 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/StatsViewController.swift @@ -0,0 +1,45 @@ +import Foundation +import Vapor +import Leaf + +internal class StatsViewController: ViewController { + + internal struct Context: ViewControllerContext { + struct Ratio: Encodable { + var key: String + var value: String + } + var pageId: String + var pageName: String + var tileHitRatios: [Ratio] + var staticMapHitRatios: [Ratio] + var markerHitRatios: [Ratio] + } + + let statsController: StatsController + + init(statsController: StatsController) { + self.statsController = statsController + } + + internal func render(request: Request) -> EventLoopFuture { + let tileHitRatios = statsController.getTileStats().map { (ratio) -> Context.Ratio in + return .init(key: ratio.key, value: ratio.value.displayValue) + } + let staticMapHitRatios = statsController.getStaticMapStats().map { (ratio) -> Context.Ratio in + return .init(key: ratio.key, value: ratio.value.displayValue) + } + let markerHitRatios = statsController.getMarkerStats().map { (ratio) -> Context.Ratio in + return .init(key: ratio.key, value: ratio.value.displayValue) + } + let context = Context( + pageId: "stats", + pageName: "Stats", + tileHitRatios: tileHitRatios, + staticMapHitRatios: staticMapHitRatios, + markerHitRatios: markerHitRatios + ) + return self.render(request: request, template: "Stats", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/StylesAddExternalViewController.swift b/Sources/SwiftTileserverCache/ViewController/StylesAddExternalViewController.swift new file mode 100644 index 0000000..d2436c3 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/StylesAddExternalViewController.swift @@ -0,0 +1,22 @@ +import Foundation +import Vapor +import Leaf + +internal class StylesAddExternalViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + } + + init() {} + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "styles", + pageName: "Add External Style" + ) + return self.render(request: request, template: "StylesAddExternal", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/StylesAddLocalViewController.swift b/Sources/SwiftTileserverCache/ViewController/StylesAddLocalViewController.swift new file mode 100644 index 0000000..29a8c81 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/StylesAddLocalViewController.swift @@ -0,0 +1,32 @@ +import Foundation +import Vapor +import Leaf + +internal class StylesAddLocalViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var previewLatitude: Double + var previewLongitude: Double + } + + let previewLatitude: Double + let previewLongitude: Double + + init() { + self.previewLatitude = Double(Environment.get("PREVIEW_LATIDUDE") ?? "") ?? 47.377105 + self.previewLongitude = Double(Environment.get("PREVIEW_LONGITUDE") ?? "") ?? 8.541655 + } + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "styles", + pageName: "Add Local Style", + previewLatitude: self.previewLatitude, + previewLongitude: self.previewLongitude + ) + return self.render(request: request, template: "StylesAddLocal", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/StylesDeleteLocalViewController.swift b/Sources/SwiftTileserverCache/ViewController/StylesDeleteLocalViewController.swift new file mode 100644 index 0000000..3f0c2c7 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/StylesDeleteLocalViewController.swift @@ -0,0 +1,24 @@ +import Foundation +import Vapor +import Leaf + +internal class StylesDeleteLocalViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var styleId: String + } + + init() {} + + internal func render(request: Request) -> EventLoopFuture { + let context = Context( + pageId: "styles", + pageName: "Delete Local Style", + styleId: request.parameters.get("id") ?? "" + ) + return self.render(request: request, template: "StylesDeleteLocal", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/StylesViewController.swift b/Sources/SwiftTileserverCache/ViewController/StylesViewController.swift new file mode 100644 index 0000000..4910775 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/StylesViewController.swift @@ -0,0 +1,40 @@ +import Foundation +import Vapor +import Leaf + +internal class StylesViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var styles: [Style] + var previewLatitude: Double + var previewLongitude: Double + var time: Double + } + + let stylesController: StylesController + let previewLatitude: Double + let previewLongitude: Double + + init(stylesController: StylesController) { + self.stylesController = stylesController + self.previewLatitude = Double(Environment.get("PREVIEW_LATIDUDE") ?? "") ?? 47.377105 + self.previewLongitude = Double(Environment.get("PREVIEW_LONGITUDE") ?? "") ?? 8.541655 + } + + internal func render(request: Request) throws -> EventLoopFuture { + return stylesController.getWithAnalysis(request: request).flatMap { (styles) in + let context = Context( + pageId: "styles", + pageName: "Styles", + styles: styles, + previewLatitude: self.previewLatitude, + previewLongitude: self.previewLongitude, + time: Date().timeIntervalSince1970 + ) + return self.render(request: request, template: "Styles", context: context) + } + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/TemplatesEditViewController.swift b/Sources/SwiftTileserverCache/ViewController/TemplatesEditViewController.swift new file mode 100644 index 0000000..ce0b5da --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/TemplatesEditViewController.swift @@ -0,0 +1,37 @@ +import Foundation +import Vapor +import Leaf + +internal class TemplatesEditViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var templateName: String? + var templateContent: String? + } + + let templatesController: TemplatesController + + init(templatesController: TemplatesController) { + self.templatesController = templatesController + } + + internal func render(request: Request) -> EventLoopFuture { + let templateName = request.parameters.get("name") + let pageName: String + if let templateName = templateName { + pageName = "Edit Template \(templateName)" + } else { + pageName = "New Template" + } + let context = Context( + pageId: "templates", + pageName: pageName, + templateName: templateName, + templateContent: (templateName != nil) ? templatesController.getTemplateContent(name: templateName!) : nil + ) + return self.render(request: request, template: "TemplatesEdit", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/TemplatesViewController.swift b/Sources/SwiftTileserverCache/ViewController/TemplatesViewController.swift new file mode 100644 index 0000000..5b4be35 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/TemplatesViewController.swift @@ -0,0 +1,34 @@ +import Foundation +import Vapor +import Leaf + +internal class TemplatesViewController: ViewController { + + internal struct Context: ViewControllerContext { + var pageId: String + var pageName: String + var templates: [String] + } + + let templatesController: TemplatesController + + init(templatesController: TemplatesController) { + self.templatesController = templatesController + } + + internal func render(request: Request) throws -> EventLoopFuture { + let templates: [String] + do { + templates = try templatesController.getTemplates() + } catch { + throw Abort(.internalServerError, reason: "Failed to get datasets: (\(error.localizedDescription))") + } + let context = Context( + pageId: "templates", + pageName: "Templates", + templates: templates + ) + return self.render(request: request, template: "Templates", context: context) + } + +} diff --git a/Sources/SwiftTileserverCache/ViewController/ViewController.swift b/Sources/SwiftTileserverCache/ViewController/ViewController.swift new file mode 100644 index 0000000..8fdec24 --- /dev/null +++ b/Sources/SwiftTileserverCache/ViewController/ViewController.swift @@ -0,0 +1,18 @@ +import Foundation +import Vapor +import Leaf + +internal protocol ViewControllerContext: Encodable { + var pageId: String { get } + var pageName: String { get } +} + +internal protocol ViewController { + func render(request: Request) throws -> EventLoopFuture +} + +internal extension ViewController { + func render(request: Request, template: String, context: E) -> EventLoopFuture where E: ViewControllerContext { + return request.view.render("Resources/Views/\(template)", context) + } +} diff --git a/Sources/SwiftTileserverCache/cachecleaners.swift b/Sources/SwiftTileserverCache/cachecleaners.swift index cf63d9c..aaa2d12 100644 --- a/Sources/SwiftTileserverCache/cachecleaners.swift +++ b/Sources/SwiftTileserverCache/cachecleaners.swift @@ -1,35 +1,35 @@ import Vapor func cachecleaners(_ app: Application) throws { - app.logger.notice("Creating missing Directories") - try? FileManager.default.createDirectory(atPath: "Cache", withIntermediateDirectories: false) - try? FileManager.default.createDirectory(atPath: "Cache/Tile", withIntermediateDirectories: false) - try? FileManager.default.createDirectory(atPath: "Cache/Static", withIntermediateDirectories: false) - try? FileManager.default.createDirectory(atPath: "Cache/StaticMulti", withIntermediateDirectories: false) - try? FileManager.default.createDirectory(atPath: "Cache/Marker", withIntermediateDirectories: false) - try? FileManager.default.createDirectory(atPath: "Cache/Regeneratable", withIntermediateDirectories: false) - if let maxAgeMinutes = UInt32(Environment.get("TILE_CACHE_MAX_AGE_MINUTES") ?? "") { - let clearDelaySeconds = UInt32(Environment.get("TILE_CACHE_DELAY_SECONDS") ?? "") ?? 900 - app.logger.notice("Starting CacheCleaner for Tiles with maxAgeMinutes: \(maxAgeMinutes) and clearDelaySeconds: \(clearDelaySeconds)") - _ = CacheCleaner(folder: "Cache/Tile", maxAgeMinutes: maxAgeMinutes, clearDelaySeconds: clearDelaySeconds) - } + _ = CacheCleaner( + folder: "Cache/Tile", + maxAgeMinutes: UInt32(Environment.get("TILE_CACHE_MAX_AGE_MINUTES") ?? ""), + clearDelaySeconds: UInt32(Environment.get("TILE_CACHE_DELAY_SECONDS") ?? "") ?? 900 + ) - if let maxAgeMinutes = UInt32(Environment.get("STATIC_CACHE_MAX_AGE_MINUTES") ?? "") { - let clearDelaySeconds = UInt32(Environment.get("STATIC_CACHE_DELAY_SECONDS") ?? "") ?? 900 - app.logger.notice("Starting CacheCleaner for Static with maxAgeMinutes: \(maxAgeMinutes) and clearDelaySeconds: \(clearDelaySeconds)") - _ = CacheCleaner(folder: "Cache/Static", maxAgeMinutes: maxAgeMinutes, clearDelaySeconds: clearDelaySeconds) - } + _ = CacheCleaner( + folder: "Cache/Static", + maxAgeMinutes: UInt32(Environment.get("STATIC_CACHE_MAX_AGE_MINUTES") ?? ""), + clearDelaySeconds: UInt32(Environment.get("STATIC_CACHE_DELAY_SECONDS") ?? "") ?? 900 + ) - if let maxAgeMinutes = UInt32(Environment.get("STATIC_MUTLI_CACHE_MAX_AGE_MINUTES") ?? "") { - let clearDelaySeconds = UInt32(Environment.get("STATIC_MULTI_CACHE_DELAY_SECONDS") ?? "") ?? 900 - app.logger.notice("Starting CacheCleaner StaticMulti Tiles with maxAgeMinutes: \(maxAgeMinutes) and clearDelaySeconds: \(clearDelaySeconds)") - _ = CacheCleaner(folder: "Cache/StaticMulti", maxAgeMinutes: maxAgeMinutes, clearDelaySeconds: clearDelaySeconds) - } + _ = CacheCleaner( + folder: "Cache/StaticMulti", + maxAgeMinutes: UInt32(Environment.get("STATIC_MUTLI_CACHE_MAX_AGE_MINUTES") ?? ""), + clearDelaySeconds: UInt32(Environment.get("STATIC_MULTI_CACHE_DELAY_SECONDS") ?? "") ?? 900 + ) + + _ = CacheCleaner( + folder: "Cache/Marker", + maxAgeMinutes: UInt32(Environment.get("MARKER_CACHE_MAX_AGE_MINUTES") ?? ""), + clearDelaySeconds: UInt32(Environment.get("MARKER_CACHE_DELAY_SECONDS") ?? "") ?? 900 + ) + + _ = CacheCleaner( + folder: "Cache/Regeneratable", + maxAgeMinutes: UInt32(Environment.get("REGENERATABLE_CACHE_MAX_AGE_MINUTES") ?? ""), + clearDelaySeconds: UInt32(Environment.get("REGENERATABLE_CACHE_DELAY_SECONDS") ?? "") ?? 900 + ) - if let maxAgeMinutes = UInt32(Environment.get("MARKER_CACHE_MAX_AGE_MINUTES") ?? "") { - let clearDelaySeconds = UInt32(Environment.get("MARKER_CACHE_DELAY_SECONDS") ?? "") ?? 900 - app.logger.notice("Starting CacheCleaner for Marker with maxAgeMinutes: \(maxAgeMinutes) and clearDelaySeconds: \(clearDelaySeconds)") - _ = CacheCleaner(folder: "Cache/Marker", maxAgeMinutes: maxAgeMinutes, clearDelaySeconds: clearDelaySeconds) - } } diff --git a/Sources/SwiftTileserverCache/configure.swift b/Sources/SwiftTileserverCache/configure.swift index 92048d7..f673c72 100644 --- a/Sources/SwiftTileserverCache/configure.swift +++ b/Sources/SwiftTileserverCache/configure.swift @@ -4,4 +4,5 @@ public func configure(_ app: Application) throws { try cachecleaners(app) try leaf(app) try routes(app) + try tileserver(app) } diff --git a/Sources/SwiftTileserverCache/routes.swift b/Sources/SwiftTileserverCache/routes.swift index 798b7cf..7bfee39 100644 --- a/Sources/SwiftTileserverCache/routes.swift +++ b/Sources/SwiftTileserverCache/routes.swift @@ -6,12 +6,14 @@ func routes(_ app: Application) throws { app.logger.critical("TILE_SERVER_URL enviroment not set. Exiting...") throw Abort(.badRequest, reason: "TILE_SERVER_URL enviroment not set") } - let tiles = ProcessInfo.processInfo.environment.filter({ element in + let externalStyles = ProcessInfo.processInfo.environment.filter({ element in return element.key.starts(with: "TILE_URL_") - }).compactMap({ element -> (style: Style, url: String) in + }).compactMap({ element -> Style in let key = element.key.replacingOccurrences(of: "TILE_URL_", with: "").replacingOccurrences(of: " ", with: "").lowercased() - return ( - style: Style(id: key.replacingOccurrences(of: "_", with: "-"), name: key.replacingOccurrences(of: "_", with: " ").capitalized), + return Style( + id: key.replacingOccurrences(of: "_", with: "-"), + name: key.replacingOccurrences(of: "_", with: " ").capitalized, + external: true, url: element.value ) }) @@ -20,14 +22,16 @@ func routes(_ app: Application) throws { app.routes.defaultMaxBodySize = ByteCount(stringLiteral: maxBodySize) } - let statsController = StatsController(tileServerURL: tileServerURL, tiles: tiles, fileToucher: FileToucher()) - app.get(use: statsController.get) - app.get("styles", use: statsController.getStyles) + let statsController = StatsController(fileToucher: FileToucher()) - let tileController = TileController(tileServerURL: tileServerURL, tiles: tiles, statsController: statsController) + let fontsController = FontsController(folder: "TileServer/Fonts", tempFolder: "Temp") + let stylesController = StylesController(tileServerURL: tileServerURL, externalStyles: externalStyles, folder: "TileServer/Styles", fontsController: fontsController) + app.get("styles", use: stylesController.get) + + let tileController = TileController(tileServerURL: tileServerURL, statsController: statsController, stylesController: stylesController) app.get("tile", ":style", ":z", ":x", ":y", ":scale", ":format", use: tileController.get) - let staticMapController = StaticMapController(tileServerURL: tileServerURL, tiles: tiles, tileController: tileController, statsController: statsController) + let staticMapController = StaticMapController(tileServerURL: tileServerURL, tileController: tileController, statsController: statsController, stylesController: stylesController) app.get("staticmap", use: staticMapController.get) app.get("staticmap", ":template", use: staticMapController.getTemplate) app.post("staticmap", ":template", use: staticMapController.postTemplate) @@ -40,4 +44,57 @@ func routes(_ app: Application) throws { app.post("multistaticmap", ":template", use: multiStaticMapController.postTemplate) app.get("multistaticmap", "pregenerated", ":id", use: multiStaticMapController.getPregenerated) app.post("multistaticmap", use: multiStaticMapController.post) + + + let protected = app.grouped("admin").grouped(app.sessions.middleware, AdminAuthenticator()) + + // Admin API + + let datasetController = DatasetsController(folder: "TileServer/Datasets") + protected.webSocket("api", "datasets", "add", onUpgrade: datasetController.download) + protected.webSocket("api", "datasets", "delete", onUpgrade: datasetController.delete) + protected.on(.POST, "api", "datasets", "add", body: .collect(maxSize: "128gb"), use: datasetController.add) + + protected.on(.POST, "api", "fonts", "add", body: .collect(maxSize: "64mb"), use: fontsController.add) + protected.delete("api", "fonts", "delete", ":name", use: fontsController.delete) + + protected.post("api", "styles", "external", "add", use: stylesController.addExternal) + protected.delete("api", "styles", "external", ":id", use: stylesController.deleteExternal) + + protected.on(.POST, "api", "styles", "local", "add", body: .collect(maxSize: "64mb"), use: stylesController.addLocal) + protected.delete("api", "styles", "local", ":id", use: stylesController.deleteLocal) + + let templatesController = TemplatesController(folder: "Templates", staticMapController: staticMapController, multiStaticMapController: multiStaticMapController) + protected.post("api", "templates", "preview", use: templatesController.preview) + protected.post("api", "templates", "save", use: templatesController.save) + protected.delete("api", "templates", "delete", ":name", use: templatesController.delete) + + // Admin Views + + protected.get("stats", use: StatsViewController(statsController: statsController).render) + + protected.get("datasets", use: DatasetsViewController(datasetsController: datasetController).render) + protected.get("datasets", "add", use: DatasetsAddViewController().render) + protected.get("datasets", "delete", ":name", use: DatasetsDeleteViewController().render) + + protected.get("fonts", use: FontsViewController(fontsController: fontsController).render) + protected.get("fonts", "add", use: FontsAddViewController().render) + + protected.get("styles", use: StylesViewController(stylesController: stylesController).render) + protected.get("styles", "external", "add", use: StylesAddExternalViewController().render) + protected.get("styles", "local", "add", use: StylesAddLocalViewController().render) + protected.get("styles", "local", "delete", ":id", use: StylesDeleteLocalViewController().render) + + protected.get("templates", use: TemplatesViewController(templatesController: templatesController).render) + protected.get("templates", "add", use: TemplatesEditViewController(templatesController: templatesController).render) + protected.get("templates", "edit", ":name", use: TemplatesEditViewController(templatesController: templatesController).render) + + app.get("admin") { (request) -> Response in + return request.redirect(to: "/admin/stats") + } + + app.get { (request) -> Response in + return request.redirect(to: "/admin/stats") + } + } diff --git a/Sources/SwiftTileserverCache/tileserver.swift b/Sources/SwiftTileserverCache/tileserver.swift new file mode 100644 index 0000000..470eb9a --- /dev/null +++ b/Sources/SwiftTileserverCache/tileserver.swift @@ -0,0 +1,11 @@ +import Vapor + +public func tileserver(_ app: Application) throws { + if !FileManager.default.fileExists(atPath: "TileServer/config.json") && + (Environment.get("ADMIN_USERNAME")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") != "" && + (Environment.get("ADMIN_PASSWORD")?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") != "" { + app.logger.info("Copying default TileServer configuration") + try FileManager.default.copyItem(atPath: "Resources/TileServer/config.json", toPath: "TileServer/config.json") + try FileManager.default.copyItem(atPath: "Resources/TileServer/Empty.mbtiles", toPath: "TileServer/Datasets/Combined.mbtiles") + } +} diff --git a/Sources/SwiftTileserverCacheApp/main.swift b/Sources/SwiftTileserverCacheApp/main.swift index 00dced0..faebecf 100644 --- a/Sources/SwiftTileserverCacheApp/main.swift +++ b/Sources/SwiftTileserverCacheApp/main.swift @@ -1,10 +1,3 @@ -// -// main.swift -// SwiftTileserverCacheApp -// -// Created by Florian Kostenzer on 01.11.19. -// - import SwiftTileserverCache import Vapor diff --git a/docker-compose.yml b/docker-compose.yml index 47e4be0..7f4402c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ version: '3.1' services: tileserver: - image: klokantech/tileserver-gl:latest + image: maptiler/tileserver-gl:latest + command: -p 8080 container_name: tileserver restart: unless-stopped tty: true @@ -15,9 +16,10 @@ services: volumes: - ./Cache:/SwiftTileserverCache/Cache - ./Templates:/SwiftTileserverCache/Templates -# - ./Markers/:/SwiftTileserverCache/Markers + - ./TileServer:/SwiftTileserverCache/TileServer + - ./Markers/:/SwiftTileserverCache/Markers environment: - TILE_SERVER_URL: http://tileserver # Leave this unless tileserver is external! + TILE_SERVER_URL: http://tileserver:8080 # Leave this unless tileserver is external! TILE_CACHE_MAX_AGE_MINUTES: 10080 # 7 Days TILE_CACHE_DELAY_SECONDS: 3600 # 1 Hour STATIC_CACHE_MAX_AGE_MINUTES: 10080 # 7 Days @@ -26,8 +28,13 @@ services: STATIC_MULTI_CACHE_DELAY_SECONDS: 900 # 15 Minutes MARKER_CACHE_MAX_AGE_MINUTES: 1440 # 1 Day MARKER_CACHE_DELAY_SECONDS: 3600 # 1 Hour +# REGENERATABLE_CACHE_MAX_AGE_MINUTES: 43200 # 1 Month +# REGENERATABLE_CACHE_DELAY_SECONDS: 3600 # 1 Hour TEMPLATES_CACHE_DELAY_SECONDS: 60 # 1 Minute -# TILE_URL_MAPBOX_SATTELITE: https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}{@scale}.{format}?access_token=xxx +# ADMIN_USERNAME: admin +# ADMIN_PASSWORD: ChangeMe! +# PREVIEW_LATIDUDE: 47.377105 +# PREVIEW_LONGITUDE: 8.541655 # MAX_BODY_SIZE: 1mb ports: - 9000:9000