Skip to content

Commit

Permalink
Block disposabled emails (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
mczachurski authored Feb 25, 2024
1 parent 6c6fc85 commit 09dd85f
Show file tree
Hide file tree
Showing 11 changed files with 3,853 additions and 2 deletions.
3,634 changes: 3,634 additions & 0 deletions Resources/disposable-emails.txt

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Sources/VernissageServer/Application+Configure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ extension Application {
self.migrations.add(Attachment.AddLicense())
self.migrations.add(User.CreateLastLoginDate())

self.migrations.add(DisposableEmail.CreateDisposableEmails())

try await self.autoMigrate()
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/VernissageServer/Errors/RegisterError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ enum RegisterError: String, Error {
case reasonIsMandatory
case invitationTokenIsInvalid
case invitationTokenHasBeenUsed
case disposableEmailCannotBeUsed
}

extension RegisterError: TerminateError {
Expand Down Expand Up @@ -49,6 +50,7 @@ extension RegisterError: TerminateError {
case .reasonIsMandatory: return "Reason is mandatory when only registration by approval is enabled."
case .invitationTokenIsInvalid: return "Invitation token is invalid."
case .invitationTokenHasBeenUsed: return "Invitation token has been used."
case .disposableEmailCannotBeUsed: return "Disposable email cannot be used."
}
}

Expand Down
55 changes: 55 additions & 0 deletions Sources/VernissageServer/Extensions/Application+Seed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ extension Application {
try await locations(on: database)
try await categories(on: database)
try await licenses(on: database)
try await disposableEmails(on: database)
}

func seedAdmin() async throws {
Expand Down Expand Up @@ -524,6 +525,46 @@ extension Application {
}
}

/// Emails that are temporary. Fresh list can be found here: https://github.com/disposable-email-domains/disposable-email-domains.
private func disposableEmails(on database: Database) async throws {
if self.environment == .testing {
self.logger.notice("Disposable emails are not initialized during testing (testing environment is set).")
return
}

self.logger.info("Disposable emails have to be added to the database, this may take a while.")
let dispisableEmailsPath = self.directory.resourcesDirectory.finished(with: "/") + "disposable-emails.txt"

guard let fileHandle = FileHandle(forReadingAtPath: dispisableEmailsPath) else {
self.logger.notice("File with disposable emails cannot be opened ('\(dispisableEmailsPath)').")
return
}

guard let fileData = try fileHandle.readToEnd() else {
self.logger.notice("Cannot read file with disposable emails ('\(dispisableEmailsPath)').")
return
}

guard let disposableEmailsString = String(data: fileData, encoding: .utf8) else {
self.logger.notice("Cannot create string from file data ('\(dispisableEmailsPath)').")
return
}

let dispisableEmailsLines = disposableEmailsString.split(separator: "\n")

let amountInDatabase = try await DisposableEmail.query(on: database).count()
if amountInDatabase == dispisableEmailsLines.count {
self.logger.info("All disposable emails has been already added to the database.")
return
}

try await dispisableEmailsLines.asyncForEach { line in
try await ensureDisposableEmailExists(on: database, domain: String(line))
}

self.logger.info("All disposable emails added.")
}

private func ensureSettingExists(on database: Database, existing settings: [Setting], key: SettingKey, value: SettingValue) async throws {
if !settings.contains(where: { $0.key == key.rawValue }) {
let setting = Setting(key: key.rawValue, value: value.value())
Expand Down Expand Up @@ -715,4 +756,18 @@ extension Application {
_ = try await localizable.save(on: database)
}
}

private func ensureDisposableEmailExists(on database: Database, domain: String) async throws {
let domainNormalized = domain.uppercased()
let disposableEmailFromDatabase = try await DisposableEmail.query(on: database)
.filter(\.$domainNormalized == domainNormalized)
.first()

if disposableEmailFromDatabase != nil {
return
}

let disposableEmail = DisposableEmail(domain: domain)
_ = try await disposableEmail.save(on: database)
}
}
37 changes: 37 additions & 0 deletions Sources/VernissageServer/Migrations/CreateDisposableEmails.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//

import Vapor
import Fluent
import SQLKit

extension DisposableEmail {
struct CreateDisposableEmails: AsyncMigration {
func prepare(on database: Database) async throws {
try await database
.schema(DisposableEmail.schema)
.field(.id, .int64, .identifier(auto: false))
.field("domain", .string, .required)
.field("domainNormalized", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "domain")
.create()

if let sqlDatabase = database as? SQLDatabase {
try await sqlDatabase
.create(index: "domainNormalizedIndex")
.on(DisposableEmail.schema)
.column("domainNormalized")
.run()
}
}

func revert(on database: Database) async throws {
try await database.schema(DisposableEmail.schema).delete()
}
}
}
43 changes: 43 additions & 0 deletions Sources/VernissageServer/Models/DisposableEmail.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//

import Fluent
import Vapor
import Frostflake

/// Information about disposabled domains. That kind of domains cannot be used during registration process.
final class DisposableEmail: Model {
static let schema: String = "DisposableEmails"

@ID(custom: .id, generatedBy: .user)
var id: Int64?

@Field(key: "domain")
var domain: String

@Field(key: "domainNormalized")
var domainNormalized: String

@Timestamp(key: "createdAt", on: .create)
var createdAt: Date?

@Timestamp(key: "updatedAt", on: .update)
var updatedAt: Date?

init() {
self.id = .init(bitPattern: Frostflake.generate())
}

convenience init(id: Int64? = nil, domain: String) {
self.init()

self.domain = domain
self.domainNormalized = domain.uppercased()
}
}

/// Allows `DisposableEmail` to be encoded to and decoded from HTTP messages.
extension DisposableEmail: Content { }
4 changes: 2 additions & 2 deletions Sources/VernissageServer/Services/ActivityPubService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,10 @@ final class ActivityPubService: ActivityPubServiceType {
}

let statusId = try status.requireID()
context.logger.warning("Deleting status '\(statusId)' (reblog) from local database.")
context.logger.info("Deleting status '\(statusId)' (reblog) from local database.")
try await statusesService.delete(id: statusId, on: context.application.db)

context.logger.warning("Recalculating reblogs for orginal status '\(orginalStatusId)' in local database.")
context.logger.info("Recalculating reblogs for orginal status '\(orginalStatusId)' in local database.")
try await statusesService.updateReblogsCount(for: orginalStatusId, on: context.application.db)
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/VernissageServer/Services/UsersService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,16 @@ final class UsersService: UsersServiceType {
if user != nil {
throw RegisterError.emailIsAlreadyConnected
}

guard let emailDomain = emailNormalized.split(separator: "@").last else {
return
}

let emailDomainString = String(emailDomain)
let disposableEmail = try await DisposableEmail.query(on: request.db).filter(\.$domainNormalized == emailDomainString).first()
if disposableEmail != nil {
throw RegisterError.disposableEmailCannotBeUsed
}
}

func updateUser(on request: Request, userDto: UserDto, userNameNormalized: String) async throws -> User {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,24 @@ final class ChangeEmailActionTests: CustomTestCase {
XCTAssertEqual(errorResponse.status, HTTPResponseStatus.badRequest, "Response http status code should be bad request (400).")
XCTAssertEqual(errorResponse.error.code, "emailIsAlreadyConnected", "Error code should be equal 'emailIsAlreadyConnected'.")
}

func testEmailShouldNotBeChangedWhenIsDisposabledEmail() async throws {

// Arrange.
_ = try await DisposableEmail.create(domain: "10minutes.org")
_ = try await User.create(userName: "kevinkrock")
let changeEmailDto = ChangeEmailDto(email: "[email protected]", redirectBaseUrl: "http://localhost:8080/")

// Act.
let errorResponse = try SharedApplication.application().getErrorResponse(
as: .user(userName: "kevinkrock", password: "p@ssword"),
to: "/account/email",
method: .PUT,
data: changeEmailDto
)

// Assert.
XCTAssertEqual(errorResponse.status, HTTPResponseStatus.badRequest, "Response http status code should be bad request (400).")
XCTAssertEqual(errorResponse.error.code, "disposableEmailCannotBeUsed", "Error code should be equal 'disposableEmailCannotBeUsed'.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -634,4 +634,29 @@ final class RegisterActionTests: CustomTestCase {
XCTAssertEqual(errorResponse.status, HTTPResponseStatus.forbidden, "Response http status code should be forbidden (403).")
XCTAssertEqual(errorResponse.error.code, "invitationTokenHasBeenUsed", "Error code should be equal 'invitationTokenHasBeenUsed'.")
}

func testUserShouldNotBeCreatedWhenRegisteringWithDisposableEmail() async throws {

// Arrange.
_ = try await DisposableEmail.create(domain: "10minutes.net")

let registerUserDto = RegisterUserDto(userName: "robingobis",
email: "[email protected]",
password: "p@ssword",
redirectBaseUrl: "http://localhost:4200",
agreement: true,
name: "Robin Gobis",
securityToken: "123")

// Act.
let errorResponse = try SharedApplication.application().getErrorResponse(
to: "/register",
method: .POST,
data: registerUserDto
)

// Assert.
XCTAssertEqual(errorResponse.status, HTTPResponseStatus.badRequest, "Response http status code should be bad request (400).")
XCTAssertEqual(errorResponse.error.code, "disposableEmailCannotBeUsed", "Error code should be equal 'disposableEmailCannotBeUsed'.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the Apache License 2.0.
//

@testable import VernissageServer
import XCTVapor
import Fluent

extension VernissageServer.DisposableEmail {
static func get(domain: String) async throws -> VernissageServer.DisposableEmail? {
return try await VernissageServer.DisposableEmail.query(on: SharedApplication.application().db)
.filter(\.$domainNormalized == domain.uppercased())
.first()
}

static func create(domain: String) async throws -> DisposableEmail {
let disposableEmail = DisposableEmail(domain: domain)
_ = try await disposableEmail.save(on: SharedApplication.application().db)
return disposableEmail
}
}

0 comments on commit 09dd85f

Please sign in to comment.