| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <Bucket | |||
| uuid = "D3C706B5-9CBD-4F91-8A68-CF08562866B5" | |||
| type = "1" | |||
| version = "2.0"> | |||
| </Bucket> | |||
| @@ -12,6 +12,7 @@ struct FuelEntryV2Controller: RouteCollection { | |||
| func boot(routes: RoutesBuilder) throws { | |||
| let fuelEntries = routes.grouped("fuel_entries") | |||
| fuelEntries.get(use: index) | |||
| fuelEntries.get("test", use: filtered) | |||
| // fuelEntries.get("with-images", use: getAllEntriesWithImages) | |||
| fuelEntries.post(use: create) | |||
| // fuelEntries.post("upload-image", use: uploadImage) | |||
| @@ -24,6 +25,90 @@ struct FuelEntryV2Controller: RouteCollection { | |||
| return try await FuelEntry.query(on: req.db).all() | |||
| } | |||
| struct filteredreturn: Content{ | |||
| var fuelentries: [FuelEntry] | |||
| var pages: Int | |||
| } | |||
| @Sendable | |||
| func filtered(req: Request) async throws -> Response { | |||
| var image: Bool | |||
| var serial: String | |||
| var tank: String | |||
| let qstring = req.query | |||
| if let qimage = try? qstring.get(Bool.self, at: "image"){ | |||
| image = qimage | |||
| } else { | |||
| image = false | |||
| } | |||
| if let qserial = try? qstring.get(String.self, at: "serial"){ | |||
| serial = qserial | |||
| } else { | |||
| serial = "" | |||
| } | |||
| if let qtank = try? qstring.get(String.self, at: "tank"){ | |||
| tank = qtank | |||
| } else { | |||
| tank = "" | |||
| } | |||
| var query = FuelEntry.query(on: req.db) | |||
| if(image){ | |||
| query = query | |||
| .field(\.$id) | |||
| .field(\.$timestamp) | |||
| .field(\.$userID) | |||
| .field(\.$product) | |||
| .field(\.$make) | |||
| .field(\.$model) | |||
| .field(\.$serialNumber) | |||
| .field(\.$tankID) | |||
| .field(\.$fuelAmount) | |||
| .field(\.$fuelType) | |||
| .field(\.$isOverride) | |||
| .field(\.$latitude) | |||
| .field(\.$longitude) | |||
| .field(\.$image) | |||
| } else { | |||
| query = query | |||
| .field(\.$id) | |||
| .field(\.$timestamp) | |||
| .field(\.$userID) | |||
| .field(\.$product) | |||
| .field(\.$make) | |||
| .field(\.$model) | |||
| .field(\.$serialNumber) | |||
| .field(\.$tankID) | |||
| .field(\.$fuelAmount) | |||
| .field(\.$fuelType) | |||
| .field(\.$isOverride) | |||
| .field(\.$latitude) | |||
| .field(\.$longitude) | |||
| } | |||
| if(serial.count > 0){ | |||
| query = query | |||
| .filter(\.$serialNumber == serial) | |||
| } | |||
| if(tank.count > 0){ | |||
| query = query | |||
| .filter(\.$tankID == tank) | |||
| } | |||
| if(image){ | |||
| let wimage = try await query.paginate(for: req).map{ | |||
| img in FuelEntry.wimage(id: img.id, timestamp: img.timestamp, userID: img.userID, latitude: img.latitude, longitutde: img.longitude, product: img.product, make: img.make, model: img.model, serialNumber: img.serialNumber, tankID: img.tankID, fuelAmount: img.fuelAmount, fuelType: img.fuelType, isOverride: img.isOverride, image: img.image!) | |||
| } | |||
| return try await wimage.encodeResponse(for: req) | |||
| } else { | |||
| let woimage = try await query.paginate(for: req).map{ | |||
| img in FuelEntry.woimage(id: img.id, timestamp: img.timestamp, userID: img.userID, latitude: img.latitude, longitutde: img.longitude, product: img.product, make: img.make, model: img.model, serialNumber: img.serialNumber, tankID: img.tankID, fuelAmount: img.fuelAmount, fuelType: img.fuelType, isOverride: img.isOverride) | |||
| } | |||
| return try await woimage.encodeResponse(for: req) | |||
| } | |||
| // let result = try await query.paginate(for: req) | |||
| // return try await result.encodeResponse(for: req) | |||
| } | |||
| // func getAllEntriesWithImages(req: Request) async throws -> [FuelEntry] { | |||
| // let fuelEntries = try await FuelEntry.query(on: req.db).all() | |||
| // | |||
| @@ -5,4 +5,78 @@ | |||
| // Created by Michiel Carman on 9/26/24. | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| import Fluent | |||
| struct UserSignup: Content { | |||
| let username: String | |||
| let password: String | |||
| } | |||
| struct NewSession: Content { | |||
| let token: String | |||
| let user: User.Public | |||
| } | |||
| extension UserSignup: Validatable { | |||
| static func validations(_ validations: inout Validations) { | |||
| validations.add("username", as: String.self, is: !.empty) | |||
| validations.add("password", as: String.self, is: .count(6...)) | |||
| } | |||
| } | |||
| struct UserController: RouteCollection { | |||
| func boot(routes: RoutesBuilder) throws { | |||
| let usersRoute = routes.grouped("users") | |||
| usersRoute.post("signup", use: create) | |||
| let tokenProtected = usersRoute.grouped(Token.authenticator()) | |||
| tokenProtected.get("me", use: getMyOwnUser) | |||
| let passwordProtected = usersRoute.grouped(User.authenticator()) | |||
| passwordProtected.post("login", use: login) | |||
| } | |||
| fileprivate func create(req: Request) throws -> EventLoopFuture<NewSession> { | |||
| try UserSignup.validate(content: req) | |||
| let userSignup = try req.content.decode(UserSignup.self) | |||
| let user = try User.create(from: userSignup) | |||
| var token: Token! | |||
| return checkIfUserExists(userSignup.username, req: req).flatMap { exists in | |||
| guard !exists else { | |||
| return req.eventLoop.future(error: UserError.usernameTaken) | |||
| } | |||
| return user.save(on: req.db) | |||
| }.flatMap { | |||
| guard let newToken = try? user.createToken(source: .signup) else { | |||
| return req.eventLoop.future(error: Abort(.internalServerError)) | |||
| } | |||
| token = newToken | |||
| return token.save(on: req.db) | |||
| }.flatMapThrowing { | |||
| NewSession(token: token.value, user: try user.asPublic()) | |||
| } | |||
| } | |||
| fileprivate func login(req: Request) throws -> EventLoopFuture<NewSession> { | |||
| let user = try req.auth.require(User.self) | |||
| let token = try user.createToken(source: .login) | |||
| return token.save(on: req.db).flatMapThrowing { | |||
| NewSession(token: token.value, user: try user.asPublic()) | |||
| } | |||
| } | |||
| func getMyOwnUser(req: Request) throws -> User.Public { | |||
| try req.auth.require(User.self).asPublic() | |||
| } | |||
| private func checkIfUserExists(_ username: String, req: Request) -> EventLoopFuture<Bool> { | |||
| User.query(on: req.db) | |||
| .filter(\.$username == username) | |||
| .first() | |||
| .map { $0 != nil } | |||
| } | |||
| } | |||
| @@ -6,3 +6,26 @@ | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| enum UserError { | |||
| case usernameTaken | |||
| } | |||
| extension UserError: AbortError { | |||
| var description: String { | |||
| reason | |||
| } | |||
| var status: HTTPResponseStatus { | |||
| switch self { | |||
| case .usernameTaken: return .conflict | |||
| } | |||
| } | |||
| var reason: String { | |||
| switch self { | |||
| case .usernameTaken: return "Username already taken" | |||
| } | |||
| } | |||
| } | |||
| @@ -82,3 +82,17 @@ struct StoreImageDataInDatabase: AsyncMigration { | |||
| } | |||
| } | |||
| struct AddDownloadedBool: AsyncMigration { | |||
| func prepare(on database: Database) async throws { | |||
| try await database.schema("fuel_entries") | |||
| .field("downloaded", .bool) | |||
| .update() | |||
| } | |||
| func revert(on database: Database) async throws { | |||
| try await database.schema("fuel_entries") | |||
| .deleteField("downloaded") | |||
| .update() | |||
| } | |||
| } | |||
| @@ -6,3 +6,26 @@ | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| import Fluent | |||
| struct CreateTokens: Migration { | |||
| func prepare(on database: Database) -> EventLoopFuture<Void> { | |||
| // 2 | |||
| database.schema(Token.schema) | |||
| // 3 | |||
| .id() | |||
| .field("user_id", .uuid, .references("user", "id")) | |||
| .field("value", .string, .required) | |||
| .unique(on: "value") | |||
| .field("source", .int, .required) | |||
| .field("created_at", .datetime, .required) | |||
| .field("expires_at", .datetime) | |||
| .create() | |||
| } | |||
| // 5 | |||
| func revert(on database: Database) -> EventLoopFuture<Void> { | |||
| database.schema(Token.schema).delete() | |||
| } | |||
| } | |||
| @@ -6,3 +6,22 @@ | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| import Fluent | |||
| struct CreateUser: AsyncMigration { | |||
| func prepare(on database: Database) async throws { | |||
| try await database.schema(User.schema) | |||
| .id() | |||
| .field("username", .string, .required) | |||
| .unique(on: "username") | |||
| .field("password_hash", .string, .required) | |||
| .field("created_at", .datetime, .required) | |||
| .field("updated_at", .datetime, .required) | |||
| .create() | |||
| } | |||
| func revert(on database: Database) async throws { | |||
| try await database.schema(User.schema).delete() | |||
| } | |||
| } | |||
| @@ -10,6 +10,39 @@ import Vapor | |||
| final class FuelEntry: Model, Content { | |||
| static let schema = "fuel_entries" | |||
| struct woimage: Content{ | |||
| let id: UUID? | |||
| let timestamp: Date | |||
| let userID: String | |||
| let latitude: Double | |||
| let longitutde: Double | |||
| let product: String | |||
| let make: String | |||
| let model: String | |||
| let serialNumber: String | |||
| let tankID: String | |||
| let fuelAmount: Double | |||
| let fuelType: String | |||
| let isOverride: Bool | |||
| } | |||
| struct wimage: Content{ | |||
| let id: UUID? | |||
| let timestamp: Date | |||
| let userID: String | |||
| let latitude: Double | |||
| let longitutde: Double | |||
| let product: String | |||
| let make: String | |||
| let model: String | |||
| let serialNumber: String | |||
| let tankID: String | |||
| let fuelAmount: Double | |||
| let fuelType: String | |||
| let isOverride: Bool | |||
| let image: Data? | |||
| } | |||
| @ID(key: .id) | |||
| var id: UUID? | |||
| @@ -53,17 +86,58 @@ final class FuelEntry: Model, Content { | |||
| @Field(key: "is_override") | |||
| var isOverride: Bool | |||
| @Field(key: "downloaded") | |||
| var downloaded: Bool? | |||
| // var imageData: Data? | |||
| init() {} | |||
| init(id: UUID? = nil, timestamp: Date, userID: String, latitude: Double, longitude: Double, image: Data?, product: String, make: String, model: String, serialNumber: String, tankID: String, fuelAmount: Double, fuelType: String, isOverride: Bool, downloaded: Bool?) { //imageData: Data? | |||
| self.id = id | |||
| self.timestamp = timestamp | |||
| self.userID = userID | |||
| self.latitude = latitude | |||
| self.longitude = longitude | |||
| self.image = image ?? nil | |||
| self.product = product | |||
| self.make = make | |||
| self.model = model | |||
| self.serialNumber = serialNumber | |||
| self.tankID = tankID | |||
| self.fuelAmount = fuelAmount | |||
| self.fuelType = fuelType | |||
| self.isOverride = isOverride | |||
| self.downloaded = downloaded ?? nil | |||
| self.image = image ?? nil | |||
| // self.imageData = imageData | |||
| } | |||
| init(id: UUID? = nil, timestamp: Date, userID: String, latitude: Double, longitude: Double, image: Data?, product: String, make: String, model: String, serialNumber: String, tankID: String, fuelAmount: Double, fuelType: String, isOverride: Bool) { //imageData: Data? | |||
| self.id = id | |||
| self.timestamp = timestamp | |||
| self.userID = userID | |||
| self.latitude = latitude | |||
| self.longitude = longitude | |||
| self.image = image | |||
| self.image = image ?? nil | |||
| self.product = product | |||
| self.make = make | |||
| self.model = model | |||
| self.serialNumber = serialNumber | |||
| self.tankID = tankID | |||
| self.fuelAmount = fuelAmount | |||
| self.fuelType = fuelType | |||
| self.isOverride = isOverride | |||
| self.downloaded = downloaded ?? nil | |||
| self.image = image ?? nil | |||
| // self.imageData = imageData | |||
| } | |||
| init(id: UUID? = nil, timestamp: Date, userID: String, latitude: Double, longitude: Double, product: String, make: String, model: String, serialNumber: String, tankID: String, fuelAmount: Double, fuelType: String, isOverride: Bool) { //imageData: Data? | |||
| self.id = id | |||
| self.timestamp = timestamp | |||
| self.userID = userID | |||
| self.latitude = latitude | |||
| self.longitude = longitude | |||
| self.image = image ?? nil | |||
| self.product = product | |||
| self.make = make | |||
| self.model = model | |||
| @@ -5,4 +5,56 @@ | |||
| // Created by Michiel Carman on 9/24/24. | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| import Fluent | |||
| enum SessionSource: Int, Content { | |||
| case signup | |||
| case login | |||
| } | |||
| final class Token: Model { | |||
| static let schema = "token" | |||
| @ID() | |||
| var id: UUID? | |||
| @Parent(key: "user_id") | |||
| var user: User | |||
| @Field(key: "value") | |||
| var value: String | |||
| @Field(key: "source") | |||
| var source: SessionSource | |||
| @Field(key: "expires_at") | |||
| var expiresAt: Date? | |||
| @Timestamp(key: "created_at", on: .create) | |||
| var createdAt: Date? | |||
| init() {} | |||
| init(id: UUID? = nil, userId: User.IDValue, token: String, | |||
| source: SessionSource, expiresAt: Date?) { | |||
| self.id = id | |||
| self.$user.id = userId | |||
| self.value = token | |||
| self.source = source | |||
| self.expiresAt = expiresAt | |||
| } | |||
| } | |||
| extension Token: ModelTokenAuthenticatable { | |||
| static let valueKey = \Token.$value | |||
| static let userKey = \Token.$user | |||
| var isValid: Bool { | |||
| guard let expiryDate = expiresAt else { | |||
| return true | |||
| } | |||
| return expiryDate > Date() | |||
| } | |||
| } | |||
| @@ -5,12 +5,68 @@ | |||
| // Created by Michiel Carman on 9/24/24. | |||
| // | |||
| import Foundation | |||
| import Vapor | |||
| import Fluent | |||
| import Vapor | |||
| final class User: Model { | |||
| @ID var id: UUID? | |||
| @Field(key: "name") var name: String | |||
| struct Public: Content { | |||
| let username: String | |||
| let id: UUID | |||
| let createdAt: Date? | |||
| let updatedAt: Date? | |||
| } | |||
| static let schema = "user" | |||
| @ID() | |||
| var id: UUID? | |||
| @Field(key: "username") | |||
| var username: String | |||
| @Field(key: "password_hash") | |||
| var passwordHash: String | |||
| @Timestamp(key: "created_at", on: .create) | |||
| var createdAt: Date? | |||
| @Timestamp(key: "updated_at", on: .update) | |||
| var updatedAt: Date? | |||
| init() {} | |||
| init(id: UUID? = nil, username: String, passwordHash: String) { | |||
| self.id = id | |||
| self.username = username | |||
| self.passwordHash = passwordHash | |||
| } | |||
| } | |||
| extension User { | |||
| static func create(from userSignup: UserSignup) throws -> User { | |||
| User(username: userSignup.username, passwordHash: try Bcrypt.hash(userSignup.password)) | |||
| } | |||
| func createToken(source: SessionSource) throws -> Token { | |||
| let calendar = Calendar(identifier: .gregorian) | |||
| let expiryDate = calendar.date(byAdding: .year, value: 1, to: Date()) | |||
| return try Token(userId: requireID(), | |||
| token: [UInt8].random(count: 16).base64, source: source, expiresAt: expiryDate) | |||
| } | |||
| func asPublic() throws -> Public { | |||
| Public(username: username, | |||
| id: try requireID(), | |||
| createdAt: createdAt, | |||
| updatedAt: updatedAt) | |||
| } | |||
| } | |||
| extension User: ModelAuthenticatable { | |||
| static let usernameKey = \User.$username | |||
| static let passwordHashKey = \User.$passwordHash | |||
| func verify(password: String) throws -> Bool { | |||
| try Bcrypt.verify(password, created: self.passwordHash) | |||
| } | |||
| } | |||
| @@ -4,6 +4,7 @@ import Vapor | |||
| // configures your application | |||
| public func configure(_ app: Application) async throws { | |||
| app.middleware.use(ErrorMiddleware.default(environment: app.environment)) | |||
| // uncomment to serve files from /Public folder | |||
| // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) | |||
| app.routes.defaultMaxBodySize = "128mb" | |||
| @@ -15,6 +16,9 @@ public func configure(_ app: Application) async throws { | |||
| app.migrations.add(AddLatitudeAndLongitudeAndRemoveGpsLocationAndImage()) | |||
| app.migrations.add(AddImageBackIn()) | |||
| app.migrations.add(StoreImageDataInDatabase()) | |||
| app.migrations.add(AddDownloadedBool()) | |||
| app.migrations.add(CreateUser()) | |||
| app.migrations.add(CreateTokens()) | |||
| try await app.autoMigrate().get() | |||
| // register routes | |||
| @@ -1,6 +1,12 @@ | |||
| import Vapor | |||
| func routes(_ app: Application) throws { | |||
| app.routes.caseInsensitive = true | |||
| app.routes.defaultMaxBodySize = "64mb"; | |||
| let v2 = app.grouped("api").grouped("v2") | |||
| try v2.register(collection: FuelEntryV2Controller()) | |||
| try v2.register(collection: UserController()) | |||
| let fuelEntryController = FuelEntryController() | |||
| app.get("fuel-entries") { req in | |||
| @@ -15,30 +21,33 @@ func routes(_ app: Application) throws { | |||
| try await fuelEntryController.delete(req: req) | |||
| } | |||
| // V2 API routes with image upload | |||
| let v2Routes = app.group("api", "v2") { v2 in | |||
| let fuelEntryV2Controller = FuelEntryV2Controller() | |||
| v2.get("fuel-entries") {req in | |||
| try await fuelEntryV2Controller.index(req: req) | |||
| } | |||
| // v2.get("fuel-entries", "with-images") {req in | |||
| // try await fuelEntryV2Controller.getAllEntriesWithImages(req: req) | |||
| // let v2Routes: () = app.group("api", "v2") { v2 in | |||
| // let fuelEntryV2Controller = FuelEntryV2Controller() | |||
| // | |||
| // v2.get("fuel-entries") {req in | |||
| // try await fuelEntryV2Controller.index(req: req) | |||
| // } | |||
| v2.post("fuel-entries") {req in | |||
| try await fuelEntryV2Controller.create(req: req) | |||
| } | |||
| // v2.post("fuel-entries", ":id", "upload-image") { req -> FuelEntry in | |||
| // return try await fuelEntryV2Controller.uploadImage(req: req) | |||
| // | |||
| //// v2.get("fuel-entries", "with-images") {req in | |||
| //// try await fuelEntryV2Controller.getAllEntriesWithImages(req: req) | |||
| //// } | |||
| // | |||
| // v2.post("fuel-entries") {req in | |||
| // try await fuelEntryV2Controller.create(req: req) | |||
| // } | |||
| v2.delete("fuel-entries", ":fuelEntryID") { req in | |||
| try await fuelEntryV2Controller.delete(req: req) | |||
| } | |||
| } | |||
| // | |||
| //// v2.post("fuel-entries", ":id", "upload-image") { req -> FuelEntry in | |||
| //// return try await fuelEntryV2Controller.uploadImage(req: req) | |||
| //// } | |||
| // | |||
| // v2.delete("fuel-entries", ":fuelEntryID") { req in | |||
| // try await fuelEntryV2Controller.delete(req: req) | |||
| // } | |||
| // } | |||
| } | |||