import Foundation import Vapor /** Expose database as S3 endpoint. This can be used to pull data from one server to another. It is used only internally to transfer data between Open-Meteo API nodes. Note: This is only a limited implementation and not fully compatible. List example: `http://127.0.0.1:8080/?list-type=2&delimiter=/&prefix=data/cmc_gem_gdps/shortwave_radiation/&apikey=123` Download exmaple `http://127.0.0.1:8080/data/cmc_gem_gdps/shortwave_radiation/chunk_1430.om?apikey=123` TODO: - Actual S3 authentication with signatures instead of simple apikeys as URL query parameter. Not required at this stage. Nginx setting: ``` location /data-internal { internal; alias /var/lib/openmeteo-api/data; } ``` */ struct S3DataController: RouteCollection { static var syncApiKeys: [String.SubSequence] = Environment.get("API_SYNC_APIKEYS")?.split(separator: ",") ?? [] static var nginxSendfilePrefix = Environment.get("NGINX_SENDFILE_PREFIX") func boot(routes: RoutesBuilder) throws { if Self.syncApiKeys.isEmpty { return } routes.get("", use: self.list) routes.get("data", "**", use: self.get) } /// https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html struct S3ListV2: Codable { let list_type: Int let delimiter: String let prefix: String let apikey: String? enum CodingKeys: String, CodingKey { case list_type = "list-type" case delimiter case prefix case apikey } } struct S3ListV2File { let name: String let modificationTime: Date let fileSize: Int } struct DownloadParams: Codable { let apikey: String? /// in megabytes per second let rate: Int? } /// List all files in a specified directory func list(_ req: Request) async throws -> Response { let params = try req.query.decode(S3ListV2.self) guard let apikey = params.apikey, Self.syncApiKeys.contains(where: {$0 == apikey}) else { throw SyncError.invalidApiKey } let path = params.prefix.sanitisedPath guard params.list_type == 2, params.delimiter == "/", path.last == "/", path.starts(with: "data/") else { throw Abort(.forbidden) } let pathNoData = path[path.index(path.startIndex, offsetBy: 5)..([.nameKey, .isDirectoryKey, .contentModificationDateKey, .fileSizeKey]) guard let directoryEnumerator = FileManager.default.enumerator(at: pathUrl, includingPropertiesForKeys: Array(resourceKeys), options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]) else { throw Abort(.forbidden) } var files = [S3ListV2File]() var directories = [String]() for case let fileURL as URL in directoryEnumerator { guard let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys), let isDirectory = resourceValues.isDirectory, let name = resourceValues.name, !name.contains("~") else { continue } if isDirectory { directories.append(name) } else { guard let modificationTime = resourceValues.contentModificationDate, let fileSize = resourceValues.fileSize else { continue } files.append(S3ListV2File(name: name, modificationTime: modificationTime, fileSize: fileSize)) } } let dateFormat = DateFormatter.awsS3DateTime let filesXml = files.map { """ \(path)\($0.name) \(dateFormat.string(from: $0.modificationTime)) \($0.fileSize) STANDARD """ }.joined(separator: "\n") let directoriesXml = directories.map { """ \(path)\($0)/ """ }.joined(separator: "\n") var headers = HTTPHeaders() headers.add(name: .contentType, value: "application/xml") return Response(status: .ok, headers: headers, body: .init(string: """ openmeteo \(path) \(files.count + directories.count) 1000 / false \(directoriesXml) \(filesXml) """)) } /// Serve file through nginx send file func get(_ req: Request) async throws -> Response { let params = try req.query.decode(DownloadParams.self) let path = req.url.path.sanitisedPath let isJson = path.hasSuffix(".json") if !isJson { /// Only require API keys for non-json calls guard let apikey = params.apikey, Self.syncApiKeys.contains(where: {$0 == apikey}) else { throw SyncError.invalidApiKey } } guard path.last != "/", path.starts(with: "/data/") else { throw Abort(.forbidden) } let pathNoData = path[path.index(path.startIndex, offsetBy: 6)..