diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/michielcarman.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/michielcarman.xcuserdatad/UserInterfaceState.xcuserstate index 406ac5f..27743b9 100644 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/michielcarman.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/michielcarman.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/.swiftpm/xcode/xcuserdata/michielcarman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/.swiftpm/xcode/xcuserdata/michielcarman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..75f58b2 --- /dev/null +++ b/.swiftpm/xcode/xcuserdata/michielcarman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Sources/App/Controllers/FuelEntryV2Controller.swift b/Sources/App/Controllers/FuelEntryV2Controller.swift index 7dbaaa7..6802b6f 100644 --- a/Sources/App/Controllers/FuelEntryV2Controller.swift +++ b/Sources/App/Controllers/FuelEntryV2Controller.swift @@ -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() // diff --git a/Sources/App/Controllers/UserController.swift b/Sources/App/Controllers/UserController.swift index cc4702c..1ede7b2 100644 --- a/Sources/App/Controllers/UserController.swift +++ b/Sources/App/Controllers/UserController.swift @@ -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 { + 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 { + 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 { + User.query(on: req.db) + .filter(\.$username == username) + .first() + .map { $0 != nil } + } +} diff --git a/Sources/App/Errors/UserErrors.swift b/Sources/App/Errors/UserErrors.swift index cc4702c..a3e4ee4 100644 --- a/Sources/App/Errors/UserErrors.swift +++ b/Sources/App/Errors/UserErrors.swift @@ -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" + } + } +} diff --git a/Sources/App/Migrations/CreateFuelEntry.swift b/Sources/App/Migrations/CreateFuelEntry.swift index ad0151d..197121d 100755 --- a/Sources/App/Migrations/CreateFuelEntry.swift +++ b/Sources/App/Migrations/CreateFuelEntry.swift @@ -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() + } +} + diff --git a/Sources/App/Migrations/CreateToken.swift b/Sources/App/Migrations/CreateToken.swift index cc4702c..3f7228f 100644 --- a/Sources/App/Migrations/CreateToken.swift +++ b/Sources/App/Migrations/CreateToken.swift @@ -6,3 +6,26 @@ // import Foundation +import Vapor +import Fluent + +struct CreateTokens: Migration { + func prepare(on database: Database) -> EventLoopFuture { + // 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 { + database.schema(Token.schema).delete() + } +} diff --git a/Sources/App/Migrations/CreateUser.swift b/Sources/App/Migrations/CreateUser.swift index cc4702c..bd3990b 100644 --- a/Sources/App/Migrations/CreateUser.swift +++ b/Sources/App/Migrations/CreateUser.swift @@ -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() + } +} diff --git a/Sources/App/Models/FuelEntry.swift b/Sources/App/Models/FuelEntry.swift index 9d60f10..2a8df86 100755 --- a/Sources/App/Models/FuelEntry.swift +++ b/Sources/App/Models/FuelEntry.swift @@ -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 diff --git a/Sources/App/Models/Token.swift b/Sources/App/Models/Token.swift index b2a1ea5..8b7e109 100644 --- a/Sources/App/Models/Token.swift +++ b/Sources/App/Models/Token.swift @@ -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() + } +} diff --git a/Sources/App/Models/User.swift b/Sources/App/Models/User.swift index caa91d5..be1cbf2 100644 --- a/Sources/App/Models/User.swift +++ b/Sources/App/Models/User.swift @@ -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) + } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 06fe51f..177900a 100755 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -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 diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index cd36958..787b8cc 100755 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -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) +// } +// } }