| @@ -3,4 +3,22 @@ | |||||
| uuid = "D3C706B5-9CBD-4F91-8A68-CF08562866B5" | uuid = "D3C706B5-9CBD-4F91-8A68-CF08562866B5" | ||||
| type = "1" | type = "1" | ||||
| version = "2.0"> | version = "2.0"> | ||||
| <Breakpoints> | |||||
| <BreakpointProxy | |||||
| BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint"> | |||||
| <BreakpointContent | |||||
| uuid = "65DB5B80-779D-462A-A47F-A0A75B1C75F8" | |||||
| shouldBeEnabled = "Yes" | |||||
| ignoreCount = "0" | |||||
| continueAfterRunningActions = "No" | |||||
| filePath = "Sources/App/Controllers/UserController.swift" | |||||
| startingColumnNumber = "9223372036854775807" | |||||
| endingColumnNumber = "9223372036854775807" | |||||
| startingLineNumber = "35" | |||||
| endingLineNumber = "35" | |||||
| landmarkName = "create(req:)" | |||||
| landmarkType = "7"> | |||||
| </BreakpointContent> | |||||
| </BreakpointProxy> | |||||
| </Breakpoints> | |||||
| </Bucket> | </Bucket> | ||||
| @@ -11,14 +11,18 @@ import FluentKit | |||||
| struct FuelEntryV2Controller: RouteCollection { | 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("test", use: filtered) | |||||
| let tokenAuthMiddleware = Token.authenticator() | |||||
| let guardMiddleware = User.guardMiddleware() | |||||
| let tokenAuthGroup = fuelEntries.grouped(tokenAuthMiddleware, guardMiddleware) | |||||
| tokenAuthGroup.get(use: index) | |||||
| tokenAuthGroup.get("test", use: filtered) | |||||
| // fuelEntries.get("with-images", use: getAllEntriesWithImages) | // fuelEntries.get("with-images", use: getAllEntriesWithImages) | ||||
| fuelEntries.post(use: create) | |||||
| tokenAuthGroup.post(use: create) | |||||
| // fuelEntries.post("upload-image", use: uploadImage) | // fuelEntries.post("upload-image", use: uploadImage) | ||||
| fuelEntries.group(":fuelEntryID") { fuelEntry in | |||||
| tokenAuthGroup.group(":fuelEntryID") { fuelEntry in | |||||
| fuelEntry.delete(use: delete) | fuelEntry.delete(use: delete) | ||||
| } | } | ||||
| } | } | ||||
| func index(req: Request) async throws -> [FuelEntry] { | func index(req: Request) async throws -> [FuelEntry] { | ||||
| @@ -26,57 +26,17 @@ extension UserSignup: Validatable { | |||||
| } | } | ||||
| struct UserController: RouteCollection { | 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()) | |||||
| func boot(routes: any RoutesBuilder) throws { | |||||
| let users = routes.grouped("users") | |||||
| users.post(use: create) | |||||
| } | } | ||||
| } | |||||
| 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()) | |||||
| @Sendable | |||||
| func create(req: Request) async throws -> Response{ | |||||
| let user = try req.content.decode(User.self) | |||||
| user.passwordHash = try Bcrypt.hash(user.passwordHash) | |||||
| try await user.save(on: req.db) | |||||
| let token = try Token.generate(for: user) | |||||
| try await token.save(on: req.db) | |||||
| return try await token.encodeResponse(for: req) | |||||
| } | } | ||||
| } | |||||
| 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 } | |||||
| } | |||||
| } | } | ||||
| @@ -29,3 +29,23 @@ struct CreateTokens: Migration { | |||||
| database.schema(Token.schema).delete() | database.schema(Token.schema).delete() | ||||
| } | } | ||||
| } | } | ||||
| struct UpdateTokens1: Migration { | |||||
| func prepare(on database: Database) -> EventLoopFuture<Void> { | |||||
| // 2 | |||||
| database.schema(Token.schema) | |||||
| // 3 | |||||
| .deleteField("source") | |||||
| .deleteField("created_at") | |||||
| .deleteField("expires_at") | |||||
| .update() | |||||
| } | |||||
| // 5 | |||||
| func revert(on database: Database) -> EventLoopFuture<Void> { | |||||
| database.schema(Token.schema) | |||||
| .field("source", .int, .required) | |||||
| .field("created_at", .datetime, .required) | |||||
| .field("expires_at", .datetime) | |||||
| .update() | |||||
| } | |||||
| } | |||||
| @@ -25,3 +25,45 @@ struct CreateUser: AsyncMigration { | |||||
| try await database.schema(User.schema).delete() | try await database.schema(User.schema).delete() | ||||
| } | } | ||||
| } | } | ||||
| struct UpdateUser1: AsyncMigration{ | |||||
| func prepare(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .deleteField("created_at") | |||||
| .deleteField("updated_at") | |||||
| .update() | |||||
| } | |||||
| func revert(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .field("created_at", .datetime, .required) | |||||
| .field("updated_at", .datetime, .required) | |||||
| .update() | |||||
| } | |||||
| } | |||||
| struct UpdateUser2_AddTokenfield: AsyncMigration { | |||||
| func prepare(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .field("token", .string) | |||||
| .update() | |||||
| } | |||||
| func revert(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .deleteField("token") | |||||
| .update() | |||||
| } | |||||
| } | |||||
| struct UpdateUser3_RemoveTokenfield: AsyncMigration { | |||||
| func prepare(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .deleteField("token") | |||||
| .update() | |||||
| } | |||||
| func revert(on database: Database) async throws { | |||||
| try await database.schema(User.schema) | |||||
| .field("token", .string) | |||||
| .update() | |||||
| } | |||||
| } | |||||
| @@ -8,12 +8,9 @@ | |||||
| import Vapor | import Vapor | ||||
| import Fluent | import Fluent | ||||
| enum SessionSource: Int, Content { | |||||
| case signup | |||||
| case login | |||||
| } | |||||
| final class Token: Model { | |||||
| final class Token: Model, Content, @unchecked Sendable { | |||||
| static let schema = "token" | static let schema = "token" | ||||
| @ID() | @ID() | ||||
| @@ -25,36 +22,29 @@ final class Token: Model { | |||||
| @Field(key: "value") | @Field(key: "value") | ||||
| var value: String | 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() {} | ||||
| init(id: UUID? = nil, userId: User.IDValue, token: String, | |||||
| source: SessionSource, expiresAt: Date?) { | |||||
| init(id: UUID? = nil, userId: User.IDValue, token: String) { | |||||
| self.id = id | self.id = id | ||||
| self.$user.id = userId | self.$user.id = userId | ||||
| self.value = token | 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 | |||||
| extension Token { | |||||
| static func generate(for user: User) throws -> Token { | |||||
| let random = [UInt8].random(count: 32).base64 | |||||
| return try Token( userId: user.requireID(), token: random) | |||||
| } | } | ||||
| } | |||||
| extension Token: ModelTokenAuthenticatable { | |||||
| typealias User = App.User | |||||
| static let valueKey = \Token.$value | |||||
| static let userKey = \Token.$user | |||||
| return expiryDate > Date() | |||||
| } | |||||
| var isValid: Bool { | |||||
| true | |||||
| } | |||||
| } | } | ||||
| @@ -8,16 +8,17 @@ | |||||
| import Fluent | import Fluent | ||||
| import Vapor | import Vapor | ||||
| final class User: Model { | |||||
| struct Public: Content { | |||||
| let username: String | |||||
| let id: UUID | |||||
| let createdAt: Date? | |||||
| let updatedAt: Date? | |||||
| } | |||||
| final class User: Model, Content { | |||||
| static let schema = "user" | static let schema = "user" | ||||
| final class Public: Content { | |||||
| var id: UUID? | |||||
| var username: String | |||||
| init(id: UUID?, username: String) { | |||||
| self.id = id | |||||
| self.username = username | |||||
| } | |||||
| } | |||||
| @ID() | @ID() | ||||
| var id: UUID? | var id: UUID? | ||||
| @@ -26,12 +27,13 @@ final class User: Model { | |||||
| @Field(key: "password_hash") | @Field(key: "password_hash") | ||||
| var passwordHash: String | var passwordHash: String | ||||
| @Timestamp(key: "created_at", on: .create) | |||||
| var createdAt: Date? | |||||
| @Timestamp(key: "updated_at", on: .update) | |||||
| var updatedAt: Date? | |||||
| // @Timestamp(key: "created_at", on: .create) | |||||
| // var createdAt: Date? | |||||
| // | |||||
| // @Timestamp(key: "updated_at", on: .update) | |||||
| // var updatedAt: Date? | |||||
| init() {} | init() {} | ||||
| @@ -41,27 +43,11 @@ final class User: Model { | |||||
| self.passwordHash = passwordHash | self.passwordHash = passwordHash | ||||
| } | } | ||||
| } | } | ||||
| extension User { | 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) | |||||
| } | |||||
| func convertToPublic() -> User.Public { | |||||
| return User.Public(id: id, username: username) | |||||
| } | |||||
| } | } | ||||
| extension User: ModelAuthenticatable { | extension User: ModelAuthenticatable { | ||||
| static let usernameKey = \User.$username | static let usernameKey = \User.$username | ||||
| static let passwordHashKey = \User.$passwordHash | static let passwordHashKey = \User.$passwordHash | ||||
| @@ -70,3 +56,8 @@ extension User: ModelAuthenticatable { | |||||
| try Bcrypt.verify(password, created: self.passwordHash) | try Bcrypt.verify(password, created: self.passwordHash) | ||||
| } | } | ||||
| } | } | ||||
| @@ -19,6 +19,10 @@ public func configure(_ app: Application) async throws { | |||||
| app.migrations.add(AddDownloadedBool()) | app.migrations.add(AddDownloadedBool()) | ||||
| app.migrations.add(CreateUser()) | app.migrations.add(CreateUser()) | ||||
| app.migrations.add(CreateTokens()) | app.migrations.add(CreateTokens()) | ||||
| app.migrations.add(UpdateUser1()) | |||||
| app.migrations.add(UpdateTokens1()) | |||||
| app.migrations.add(UpdateUser2_AddTokenfield()) | |||||
| app.migrations.add(UpdateUser3_RemoveTokenfield()) | |||||
| try await app.autoMigrate().get() | try await app.autoMigrate().get() | ||||
| // register routes | // register routes | ||||