| @@ -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 { | func boot(routes: RoutesBuilder) throws { | ||||
| let fuelEntries = routes.grouped("fuel_entries") | let fuelEntries = routes.grouped("fuel_entries") | ||||
| fuelEntries.get(use: index) | fuelEntries.get(use: index) | ||||
| fuelEntries.get("test", use: filtered) | |||||
| // fuelEntries.get("with-images", use: getAllEntriesWithImages) | // fuelEntries.get("with-images", use: getAllEntriesWithImages) | ||||
| fuelEntries.post(use: create) | fuelEntries.post(use: create) | ||||
| // fuelEntries.post("upload-image", use: uploadImage) | // fuelEntries.post("upload-image", use: uploadImage) | ||||
| @@ -24,6 +25,90 @@ struct FuelEntryV2Controller: RouteCollection { | |||||
| return try await FuelEntry.query(on: req.db).all() | 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] { | // func getAllEntriesWithImages(req: Request) async throws -> [FuelEntry] { | ||||
| // let fuelEntries = try await FuelEntry.query(on: req.db).all() | // let fuelEntries = try await FuelEntry.query(on: req.db).all() | ||||
| // | // | ||||
| @@ -5,4 +5,78 @@ | |||||
| // Created by Michiel Carman on 9/26/24. | // 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 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 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 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 { | final class FuelEntry: Model, Content { | ||||
| static let schema = "fuel_entries" | 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) | @ID(key: .id) | ||||
| var id: UUID? | var id: UUID? | ||||
| @@ -53,17 +86,58 @@ final class FuelEntry: Model, Content { | |||||
| @Field(key: "is_override") | @Field(key: "is_override") | ||||
| var isOverride: Bool | var isOverride: Bool | ||||
| @Field(key: "downloaded") | |||||
| var downloaded: Bool? | |||||
| // var imageData: Data? | // var imageData: Data? | ||||
| init() {} | 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? | 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.id = id | ||||
| self.timestamp = timestamp | self.timestamp = timestamp | ||||
| self.userID = userID | self.userID = userID | ||||
| self.latitude = latitude | self.latitude = latitude | ||||
| self.longitude = longitude | 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.product = product | ||||
| self.make = make | self.make = make | ||||
| self.model = model | self.model = model | ||||
| @@ -5,4 +5,56 @@ | |||||
| // Created by Michiel Carman on 9/24/24. | // 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. | // Created by Michiel Carman on 9/24/24. | ||||
| // | // | ||||
| import Foundation | |||||
| import Vapor | |||||
| import Fluent | import Fluent | ||||
| import Vapor | |||||
| final class User: Model { | 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 | // configures your application | ||||
| public func configure(_ app: Application) async throws { | public func configure(_ app: Application) async throws { | ||||
| app.middleware.use(ErrorMiddleware.default(environment: app.environment)) | |||||
| // uncomment to serve files from /Public folder | // uncomment to serve files from /Public folder | ||||
| // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) | // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) | ||||
| app.routes.defaultMaxBodySize = "128mb" | app.routes.defaultMaxBodySize = "128mb" | ||||
| @@ -15,6 +16,9 @@ public func configure(_ app: Application) async throws { | |||||
| app.migrations.add(AddLatitudeAndLongitudeAndRemoveGpsLocationAndImage()) | app.migrations.add(AddLatitudeAndLongitudeAndRemoveGpsLocationAndImage()) | ||||
| app.migrations.add(AddImageBackIn()) | app.migrations.add(AddImageBackIn()) | ||||
| app.migrations.add(StoreImageDataInDatabase()) | app.migrations.add(StoreImageDataInDatabase()) | ||||
| app.migrations.add(AddDownloadedBool()) | |||||
| app.migrations.add(CreateUser()) | |||||
| app.migrations.add(CreateTokens()) | |||||
| try await app.autoMigrate().get() | try await app.autoMigrate().get() | ||||
| // register routes | // register routes | ||||
| @@ -1,6 +1,12 @@ | |||||
| import Vapor | import Vapor | ||||
| func routes(_ app: Application) throws { | 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() | let fuelEntryController = FuelEntryController() | ||||
| app.get("fuel-entries") { req in | app.get("fuel-entries") { req in | ||||
| @@ -15,30 +21,33 @@ func routes(_ app: Application) throws { | |||||
| try await fuelEntryController.delete(req: req) | try await fuelEntryController.delete(req: req) | ||||
| } | } | ||||
| // V2 API routes with image upload | // 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) | |||||
| // } | |||||
| // } | |||||
| } | } | ||||