├── .swiftlint.yml ├── .codecov.yml ├── Tests └── SteamPressTests │ ├── Helpers │ ├── SteamPressTestError.swift │ ├── TestWorld+TestDataBuilder.swift │ ├── TestWorld.swift │ ├── TestWorld+Responses.swift │ ├── TestWorld+Application.swift │ └── TestDataBuilder.swift │ ├── Fakes │ ├── PasswordHasherChoice.swift │ ├── PlaintextHasher.swift │ ├── StubbedRandomNumberGenerator.swift │ ├── CapturingViewRenderer.swift │ ├── ReversedPasswordHasher.swift │ └── Presenters │ │ ├── CapturingAdminPresenter.swift │ │ └── CapturingBlogPresenter.swift │ ├── Models │ └── LoginData.swift │ ├── BlogTests │ ├── DisabledBlogTagTests.swift │ ├── PostTests.swift │ ├── SearchTests.swift │ ├── IndexTests.swift │ ├── TagTests.swift │ └── AuthorTests.swift │ ├── APITests │ └── APITagControllerTests.swift │ ├── AdminTests │ ├── AdminPageTests.swift │ └── AccessControlTests.swift │ ├── ProviderTests.swift │ └── ViewTests │ └── BlogViewTests.swift ├── .gitignore ├── Sources └── SteamPress │ ├── Config │ ├── PaginationInformation.swift │ ├── BlogAdminPageInformation.swift │ ├── BlogGlobalPageInformation.swift │ └── FeedInformation.swift │ ├── Models │ ├── Errors │ │ ├── CreatePostErrors.swift │ │ └── CreateUserErrors.swift │ ├── FormData │ │ ├── ResetPasswordData.swift │ │ ├── LoginData.swift │ │ ├── CreatePostData.swift │ │ └── CreateUserData.swift │ ├── Contexts │ │ ├── AllAuthorsPageContext.swift │ │ ├── AllTagsPageContext.swift │ │ ├── ContextViews │ │ │ ├── BlogTagWithPostCount.swift │ │ │ ├── ViewBlogAuthor.swift │ │ │ ├── ViewBlogTag.swift │ │ │ └── ViewBlogPost.swift │ │ ├── PaginationTagInformation.swift │ │ ├── Admin │ │ │ ├── ResetPasswordPageContext.swift │ │ │ ├── AdminPageContext.swift │ │ │ ├── CreatePostPageContext.swift │ │ │ └── CreateUserPageContext.swift │ │ ├── TagPageContext.swift │ │ ├── SearchPageContext.swift │ │ ├── AuthorPageContext.swift │ │ ├── LoginPageContext.swift │ │ ├── BlogPostPageContext.swift │ │ └── BlogIndexPageContext.swift │ ├── BlogTag.swift │ ├── BlogUser.swift │ └── BlogPost.swift │ ├── Services │ ├── SteamPressRandomNumberGenerator.swift │ ├── RealRandomNumberGenerator.swift │ ├── LongPostDateFormatter.swift │ └── NumericPostFormatter.swift │ ├── Extensions │ ├── String+Optional+Whitespace.swift │ ├── String+Random.swift │ ├── BlogUser+Information.swift │ ├── BCrypt+PasswordHasher.swift │ ├── Request+PaginationInformation.swift │ ├── Request+PageInformation.swift │ └── URL+Converters.swift │ ├── Controllers │ ├── API │ │ ├── APIController.swift │ │ └── APITagController.swift │ ├── FeedController.swift │ ├── BlogAdminController.swift │ ├── Admin │ │ ├── LoginController.swift │ │ ├── PostAdminController.swift │ │ └── UserAdminController.swift │ └── BlogController.swift │ ├── SteamPressError.swift │ ├── BlogPathCreator.swift │ ├── Middleware │ ├── BlogRememberMeMiddleware.swift │ ├── BlogLoginRedirectAuthMiddleware.swift │ └── BlogAuthSessionsMiddleware.swift │ ├── Presenters │ ├── BlogAdminPresenter.swift │ ├── BlogPresenter.swift │ ├── ViewBlogAdminPresenter.swift │ └── ViewBlogPresenter.swift │ ├── Provider.swift │ ├── Feed Generators │ ├── RSSFeedGenerator.swift │ └── AtomFeedGenerator.swift │ ├── Repositories │ └── SteamPressRepository.swift │ └── Views │ └── PaginatorTag.swift ├── Package.swift ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | - Tests 4 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "0...100" 3 | ignore: 4 | - "Tests/" 5 | - ".build/" 6 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/SteamPressTestError.swift: -------------------------------------------------------------------------------- 1 | struct SteamPressTestError: Error { 2 | let name: String 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | DerivedData/ 6 | Package.pins 7 | Package.resolved 8 | .swiftpm 9 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/PasswordHasherChoice.swift: -------------------------------------------------------------------------------- 1 | enum PasswordHasherChoice { 2 | case plaintext 3 | case real 4 | case reversed 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Config/PaginationInformation.swift: -------------------------------------------------------------------------------- 1 | struct PaginationInformation { 2 | let page: Int 3 | let offset: Int 4 | let postsPerPage: Int 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Errors/CreatePostErrors.swift: -------------------------------------------------------------------------------- 1 | struct CreatePostErrors { 2 | let errors: [String] 3 | let titleError: Bool 4 | let contentsError: Bool 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Services/SteamPressRandomNumberGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol SteamPressRandomNumberGenerator: Service { 4 | func getNumber() -> Int 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/FormData/ResetPasswordData.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ResetPasswordData: Content { 4 | let password: String? 5 | let confirmPassword: String? 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/FormData/LoginData.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct LoginData: Content { 4 | let username: String? 5 | let password: String? 6 | let rememberMe: Bool? 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/AllAuthorsPageContext.swift: -------------------------------------------------------------------------------- 1 | struct AllAuthorsPageContext: Encodable { 2 | let pageInformation: BlogGlobalPageInformation 3 | let authors: [ViewBlogAuthor] 4 | } 5 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/AllTagsPageContext.swift: -------------------------------------------------------------------------------- 1 | struct AllTagsPageContext: Encodable { 2 | let title: String 3 | let tags: [BlogTagWithPostCount] 4 | let pageInformation: BlogGlobalPageInformation 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/ContextViews/BlogTagWithPostCount.swift: -------------------------------------------------------------------------------- 1 | struct BlogTagWithPostCount: Encodable { 2 | let tagID: Int 3 | let name: String 4 | let postCount: Int 5 | let urlEncodedName: String 6 | } 7 | 8 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/PaginationTagInformation.swift: -------------------------------------------------------------------------------- 1 | public struct PaginationTagInformation: Encodable { 2 | public let currentPage: Int 3 | public let totalPages: Int 4 | public let currentQuery: String? 5 | } 6 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Errors/CreateUserErrors.swift: -------------------------------------------------------------------------------- 1 | struct CreateUserErrors { 2 | let errors: [String] 3 | let passwordError: Bool 4 | let confirmPasswordError: Bool 5 | let nameError: Bool 6 | let usernameError: Bool 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SteamPress/Config/BlogAdminPageInformation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct BlogAdminPageInformation: Codable { 4 | public let loggedInUser: BlogUser 5 | public let websiteURL: URL 6 | public let currentPageURL: URL 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SteamPress/Services/RealRandomNumberGenerator.swift: -------------------------------------------------------------------------------- 1 | public struct RealRandomNumberGenerator: SteamPressRandomNumberGenerator { 2 | public init() {} 3 | 4 | public func getNumber() -> Int { 5 | return Int.random(in: 1...999) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/Admin/ResetPasswordPageContext.swift: -------------------------------------------------------------------------------- 1 | struct ResetPasswordPageContext: Encodable { 2 | let errors: [String]? 3 | let passwordError: Bool? 4 | let confirmPasswordError: Bool? 5 | let pageInformation: BlogAdminPageInformation 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/PlaintextHasher.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import SteamPress 3 | 4 | struct PlaintextHasher: PasswordHasher { 5 | func hash(_ plaintext: LosslessDataConvertible) throws -> String { 6 | return String.convertFromData(plaintext.convertToData()) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/StubbedRandomNumberGenerator.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | 3 | struct StubbedRandomNumberGenerator: SteamPressRandomNumberGenerator { 4 | let numberToReturn: Int 5 | 6 | func getNumber() -> Int { 7 | return numberToReturn 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/FormData/CreatePostData.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct CreatePostData: Content { 4 | let title: String? 5 | let contents: String? 6 | let publish: Bool? 7 | let draft: Bool? 8 | let tags: [String] 9 | let updateSlugURL: Bool? 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/TagPageContext.swift: -------------------------------------------------------------------------------- 1 | struct TagPageContext: Encodable { 2 | let tag: BlogTag 3 | let pageInformation: BlogGlobalPageInformation 4 | let posts: [ViewBlogPost] 5 | let tagPage = true 6 | let postCount: Int 7 | let paginationTagInformation: PaginationTagInformation 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/String+Optional+Whitespace.swift: -------------------------------------------------------------------------------- 1 | extension Optional where Wrapped == String { 2 | func isEmptyOrWhitespace() -> Bool { 3 | guard let string = self else { 4 | return true 5 | } 6 | 7 | return string.trimmingCharacters(in: .whitespaces).isEmpty 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/SearchPageContext.swift: -------------------------------------------------------------------------------- 1 | struct SearchPageContext: Encodable { 2 | let title = "Search Blog" 3 | let searchTerm: String? 4 | let posts: [ViewBlogPost] 5 | let totalResults: Int 6 | let pageInformation: BlogGlobalPageInformation 7 | let paginationTagInformation: PaginationTagInformation 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/API/APIController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct APIController: RouteCollection { 4 | func boot(router: Router) throws { 5 | let apiRoutes = router.grouped("api") 6 | 7 | let apiTagController = APITagController() 8 | try apiRoutes.register(collection: apiTagController) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/String+Random.swift: -------------------------------------------------------------------------------- 1 | import Crypto 2 | 3 | extension String { 4 | public static func random(length: Int = 12) throws -> String { 5 | let randomData = try CryptoRandom().generateData(count: length) 6 | let randomString = randomData.base64EncodedString() 7 | return randomString 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/BlogTag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | // MARK: - Model 4 | 5 | public final class BlogTag: Codable { 6 | 7 | public var tagID: Int? 8 | public var name: String 9 | 10 | public init(id: Int? = nil, name: String) { 11 | self.tagID = id 12 | self.name = name 13 | } 14 | } 15 | 16 | extension BlogTag: Content {} 17 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/AuthorPageContext.swift: -------------------------------------------------------------------------------- 1 | struct AuthorPageContext: Encodable { 2 | let author: BlogUser 3 | let posts: [ViewBlogPost] 4 | let pageInformation: BlogGlobalPageInformation 5 | let myProfile: Bool 6 | let profilePage = true 7 | let postCount: Int 8 | let paginationTagInformation: PaginationTagInformation 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/LoginPageContext.swift: -------------------------------------------------------------------------------- 1 | struct LoginPageContext: Encodable { 2 | let title = "Log In" 3 | let errors: [String]? 4 | let loginWarning: Bool 5 | let username: String? 6 | let usernameError: Bool 7 | let passwordError: Bool 8 | let rememberMe: Bool 9 | let pageInformation: BlogGlobalPageInformation 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/BlogUser+Information.swift: -------------------------------------------------------------------------------- 1 | extension Array where Element: BlogUser { 2 | func getAuthorName(id: Int) -> String { 3 | return self.filter { $0.userID == id }.first?.name ?? "" 4 | } 5 | 6 | func getAuthorUsername(id: Int) -> String { 7 | return self.filter { $0.userID == id }.first?.username ?? "" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/BlogPostPageContext.swift: -------------------------------------------------------------------------------- 1 | struct BlogPostPageContext: Encodable { 2 | let title: String 3 | let post: ViewBlogPost 4 | let author: BlogUser 5 | let blogPostPage = true 6 | let pageInformation: BlogGlobalPageInformation 7 | let postImage: String? 8 | let postImageAlt: String? 9 | let shortSnippet: String 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/BlogIndexPageContext.swift: -------------------------------------------------------------------------------- 1 | struct BlogIndexPageContext: Encodable { 2 | let posts: [ViewBlogPost] 3 | let tags: [ViewBlogTag] 4 | let authors: [BlogUser] 5 | let pageInformation: BlogGlobalPageInformation 6 | let title = "Blog" 7 | let blogIndexPage = true 8 | let paginationTagInformation: PaginationTagInformation 9 | } 10 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/ContextViews/ViewBlogAuthor.swift: -------------------------------------------------------------------------------- 1 | struct ViewBlogAuthor: Encodable { 2 | let userID: Int 3 | let name: String 4 | let username: String 5 | let resetPasswordRequired: Bool 6 | let profilePicture: String? 7 | let twitterHandle: String? 8 | let biography: String? 9 | let tagline: String? 10 | let postCount: Int 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/Admin/AdminPageContext.swift: -------------------------------------------------------------------------------- 1 | struct AdminPageContext: Encodable { 2 | let errors: [String]? 3 | let publishedPosts: [ViewBlogPostWithoutTags] 4 | let draftPosts: [ViewBlogPostWithoutTags] 5 | let users: [BlogUser] 6 | let pageInformation: BlogAdminPageInformation 7 | let blogAdminPage = true 8 | let title = "Blog Admin" 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Models/LoginData.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct LoginData: Content { 4 | let username: String? 5 | let password: String? 6 | let rememberMe: Bool? 7 | 8 | init(username: String?, password: String?, rememberMe: Bool? = nil) { 9 | self.username = username 10 | self.password = password 11 | self.rememberMe = rememberMe 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/BCrypt+PasswordHasher.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Crypto 3 | 4 | public protocol PasswordHasher: Service { 5 | func hash(_ plaintext: LosslessDataConvertible) throws -> String 6 | } 7 | 8 | extension BCryptDigest: PasswordHasher { 9 | public func hash(_ plaintext: LosslessDataConvertible) throws -> String { 10 | return try self.hash(plaintext, salt: nil) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SteamPress/SteamPressError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct SteamPressError: AbortError, Debuggable { 4 | 5 | let identifier: String 6 | let reason: String 7 | 8 | init(identifier: String, _ reason: String) { 9 | self.identifier = identifier 10 | self.reason = reason 11 | } 12 | 13 | var status: HTTPResponseStatus { 14 | return .internalServerError 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SteamPress/Config/BlogGlobalPageInformation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct BlogGlobalPageInformation: Encodable { 4 | public let disqusName: String? 5 | public let siteTwitterHandle: String? 6 | public let googleAnalyticsIdentifier: String? 7 | public let loggedInUser: BlogUser? 8 | public let websiteURL: URL 9 | public let currentPageURL: URL 10 | public let currentPageEncodedURL: String 11 | } 12 | -------------------------------------------------------------------------------- /Sources/SteamPress/Services/LongPostDateFormatter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | 4 | struct LongPostDateFormatter: ServiceType { 5 | static func makeService(for container: Container) throws -> LongPostDateFormatter { 6 | return .init() 7 | } 8 | 9 | let formatter: DateFormatter 10 | 11 | init() { 12 | self.formatter = DateFormatter() 13 | formatter.dateFormat = "EEEE, MMM d, yyyy" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SteamPress/Config/FeedInformation.swift: -------------------------------------------------------------------------------- 1 | public struct FeedInformation { 2 | let title: String? 3 | let description: String? 4 | let copyright: String? 5 | let imageURL: String? 6 | 7 | public init(title: String? = nil, description: String? = nil, copyright: String? = nil, imageURL: String? = nil) { 8 | self.title = title 9 | self.description = description 10 | self.copyright = copyright 11 | self.imageURL = imageURL 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/API/APITagController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct APITagController: RouteCollection { 4 | func boot(router: Router) throws { 5 | let tagsRoute = router.grouped("tags") 6 | tagsRoute.get(use: allTagsHandler) 7 | } 8 | 9 | func allTagsHandler(_ req: Request) throws -> EventLoopFuture<[BlogTag]> { 10 | let repository = try req.make(BlogTagRepository.self) 11 | return repository.getAllTags(on: req) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/Admin/CreatePostPageContext.swift: -------------------------------------------------------------------------------- 1 | struct CreatePostPageContext: Encodable { 2 | let title: String 3 | let editing: Bool 4 | let post: BlogPost? 5 | let draft: Bool 6 | let errors: [String]? 7 | let titleSupplied: String? 8 | let contentsSupplied: String? 9 | let tagsSupplied: [String]? 10 | let slugURLSupplied: String? 11 | let titleError: Bool 12 | let contentsError: Bool 13 | let postPathPrefix: String 14 | let pageInformation: BlogAdminPageInformation 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SteamPress/Services/NumericPostFormatter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | 4 | struct NumericPostDateFormatter: ServiceType { 5 | static func makeService(for container: Container) throws -> NumericPostDateFormatter { 6 | return .init() 7 | } 8 | 9 | let formatter: DateFormatter 10 | 11 | init() { 12 | self.formatter = DateFormatter() 13 | self.formatter.calendar = Calendar(identifier: .iso8601) 14 | self.formatter.locale = Locale(identifier: "en_US_POSIX") 15 | self.formatter.timeZone = TimeZone(secondsFromGMT: 0) 16 | self.formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/CapturingViewRenderer.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | class CapturingViewRenderer: ViewRenderer, Service { 4 | var shouldCache = false 5 | var worker: Worker 6 | 7 | init(worker: Worker) { 8 | self.worker = worker 9 | } 10 | 11 | private(set) var capturedContext: Encodable? 12 | private(set) var templatePath: String? 13 | func render(_ path: String, _ context: E, userInfo: [AnyHashable: Any]) -> EventLoopFuture where E: Encodable { 14 | self.capturedContext = context 15 | self.templatePath = path 16 | return Future.map(on: worker) { return View(data: "Test".convertToData()) } 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/Admin/CreateUserPageContext.swift: -------------------------------------------------------------------------------- 1 | struct CreateUserPageContext: Encodable { 2 | let title = "Create User" 3 | let editing: Bool 4 | let errors: [String]? 5 | let nameSupplied: String? 6 | let nameError: Bool 7 | let usernameSupplied: String? 8 | let usernameError: Bool 9 | let passwordError: Bool 10 | let confirmPasswordError: Bool 11 | let resetPasswordOnLoginSupplied: Bool 12 | let userID: Int? 13 | let twitterHandleSupplied: String? 14 | let profilePictureSupplied: String? 15 | let biographySupplied: String? 16 | let taglineSupplied: String? 17 | let pageInformation: BlogAdminPageInformation 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/ReversedPasswordHasher.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | import SteamPress 4 | 5 | struct ReversedPasswordHasher: PasswordHasher, PasswordVerifier { 6 | func hash(_ plaintext: LosslessDataConvertible) throws -> String { 7 | return String(String.convertFromData(plaintext.convertToData()).reversed()) 8 | } 9 | 10 | func verify(_ password: LosslessDataConvertible, created hash: LosslessDataConvertible) throws -> Bool { 11 | let passwordString = String.convertFromData(password.convertToData()) 12 | let passwordHash = String.convertFromData(hash.convertToData()) 13 | return passwordString == String(passwordHash.reversed()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/Request+PaginationInformation.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | func getPaginationInformation(postsPerPage: Int) -> PaginationInformation { 5 | if let pageQuery = try? query.get(Int.self, at: "page") { 6 | guard pageQuery > 0 else { 7 | return PaginationInformation(page: 1, offset: 0, postsPerPage: postsPerPage) 8 | } 9 | let offset = (pageQuery - 1) * postsPerPage 10 | return PaginationInformation(page: pageQuery, offset: offset, postsPerPage: postsPerPage) 11 | } else { 12 | return PaginationInformation(page: 1, offset: 0, postsPerPage: postsPerPage) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/DisabledBlogTagTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | 4 | class DisabledBlogTagTests: XCTestCase { 5 | func testDisabledBlogTagsPath() throws { 6 | var testWorld = try TestWorld.create(enableTagPages: false) 7 | _ = try testWorld.createTag("Engineering") 8 | var tagResponse: Response? = try testWorld.getResponse(to: "/tags/Engineering") 9 | var allTagsResponse: Response? = try testWorld.getResponse(to: "/tags") 10 | 11 | XCTAssertEqual(.notFound, tagResponse?.http.status) 12 | XCTAssertEqual(.notFound, allTagsResponse?.http.status) 13 | 14 | tagResponse = nil 15 | allTagsResponse = nil 16 | 17 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SteamPress", 7 | products: [ 8 | .library(name: "SteamPress", targets: ["SteamPress"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), 12 | .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.0.0"), 13 | .package(url: "https://github.com/vapor-community/markdown.git", from: "0.4.0"), 14 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.0"), 15 | ], 16 | targets: [ 17 | .target(name: "SteamPress", dependencies: ["Vapor", "SwiftSoup", "SwiftMarkdown", "Authentication"]), 18 | .testTarget(name: "SteamPressTests", dependencies: ["SteamPress"]), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/APITests/APITagControllerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | import SteamPress 4 | 5 | class APITagControllerTests: XCTestCase { 6 | 7 | // MARK: - Tests 8 | 9 | func testThatAllTagsAreReturnedFromAPI() throws { 10 | var testWorld = try TestWorld.create() 11 | 12 | let tag1 = try testWorld.context.repository.addTag(name: "Vapor3") 13 | let tag2 = try testWorld.context.repository.addTag(name: "Engineering") 14 | 15 | let tags = try testWorld.getResponse(to: "/api/tags", decodeTo: [BlogTagJSON].self) 16 | 17 | XCTAssertEqual(tags[0].name, tag1.name) 18 | XCTAssertEqual(tags[1].name, tag2.name) 19 | 20 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 21 | } 22 | 23 | } 24 | 25 | struct BlogTagJSON: Content { 26 | let name: String 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/FormData/CreateUserData.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct CreateUserData: Content { 4 | let name: String? 5 | let username: String? 6 | let password: String? 7 | let confirmPassword: String? 8 | let profilePicture: String? 9 | let tagline: String? 10 | let biography: String? 11 | let twitterHandle: String? 12 | let resetPasswordOnLogin: Bool? 13 | } 14 | 15 | extension CreateUserData: Validatable, Reflectable { 16 | static func validations() throws -> Validations { 17 | var validations = Validations(CreateUserData.self) 18 | let usernameCharacterSet = CharacterSet(charactersIn: "-_") 19 | let usernameValidationCharacters = Validator.characterSet(.alphanumerics + usernameCharacterSet) 20 | try validations.add(\.username, usernameValidationCharacters || .nil) 21 | return validations 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | jobs: 5 | xenial: 6 | container: 7 | image: vapor/swift:5.1-xenial 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - run: swift test --enable-test-discovery --enable-code-coverage 12 | bionic: 13 | container: 14 | image: vapor/swift:5.1-bionic 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Run Bionic Tests 19 | run: swift test --enable-test-discovery --enable-code-coverage 20 | - name: Setup container for codecov upload 21 | run: apt-get update && apt-get install curl 22 | - name: Process coverage file 23 | run: llvm-cov show .build/x86_64-unknown-linux/debug/SteamPressPackageTests.xctest -instr-profile=.build/x86_64-unknown-linux/debug/codecov/default.profdata > coverage.txt 24 | - name: Upload code coverage 25 | uses: codecov/codecov-action@v1 26 | with: 27 | token: ${{ secrets.CODECOV_UPLOAD_KEY }} 28 | file: coverage.txt 29 | -------------------------------------------------------------------------------- /Sources/SteamPress/BlogPathCreator.swift: -------------------------------------------------------------------------------- 1 | struct BlogPathCreator { 2 | 3 | let blogPath: String? 4 | 5 | func createPath(for path: String?, query: String? = nil) -> String { 6 | var createdPath = constructPath(from: path) 7 | 8 | if let query = query { 9 | createdPath = "\(createdPath)?\(query)" 10 | } 11 | 12 | return createdPath 13 | } 14 | 15 | fileprivate func constructPath(from path: String?) -> String { 16 | if path == blogPath { 17 | if let index = blogPath { 18 | return "/\(index)/" 19 | } else { 20 | return "/" 21 | } 22 | } 23 | if let index = blogPath { 24 | if let pathSuffix = path { 25 | return "/\(index)/\(pathSuffix)/" 26 | } else { 27 | return "/\(index)/" 28 | } 29 | } else { 30 | guard let path = path else { 31 | return "/" 32 | } 33 | return "/\(path)/" 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/SteamPress/Middleware/BlogRememberMeMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct BlogRememberMeMiddleware: Middleware, ServiceType { 4 | 5 | public static func makeService(for container: Container) throws -> BlogRememberMeMiddleware { 6 | return .init() 7 | } 8 | 9 | public func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture { 10 | return try next.respond(to: request).map { response in 11 | if let rememberMe = try request.session()["SteamPressRememberMe"], rememberMe == "YES" { 12 | if var steampressCookie = response.http.cookies["steampress-session"] { 13 | let oneYear: TimeInterval = 60 * 60 * 24 * 365 14 | steampressCookie.expires = Date().addingTimeInterval(oneYear) 15 | response.http.cookies["steampress-session"] = steampressCookie 16 | try request.session()["SteamPressRememberMe"] = nil 17 | } 18 | } 19 | return response 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/Request+PageInformation.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | func pageInformation() throws -> BlogGlobalPageInformation { 5 | let currentURL = try self.url() 6 | guard let currentEncodedURL = currentURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { 7 | throw SteamPressError(identifier: "STEAMPRESS", "Failed to convert page url to URL encoded") 8 | } 9 | return try BlogGlobalPageInformation(disqusName: Environment.get("BLOG_DISQUS_NAME"), siteTwitterHandle: Environment.get("BLOG_SITE_TWITTER_HANDLE"), googleAnalyticsIdentifier: Environment.get("BLOG_GOOGLE_ANALYTICS_IDENTIFIER"), loggedInUser: authenticated(BlogUser.self), websiteURL: self.rootUrl(), currentPageURL: currentURL, currentPageEncodedURL: currentEncodedURL) 10 | } 11 | 12 | func adminPageInfomation() throws -> BlogAdminPageInformation { 13 | return try BlogAdminPageInformation(loggedInUser: requireAuthenticated(BlogUser.self), websiteURL: self.rootUrl(), currentPageURL: self.url()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SteamPress/Middleware/BlogLoginRedirectAuthMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct BlogLoginRedirectAuthMiddleware: Middleware { 4 | 5 | let pathCreator: BlogPathCreator 6 | 7 | func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture { 8 | do { 9 | let user = try request.requireAuthenticated(BlogUser.self) 10 | let resetPasswordPath = pathCreator.createPath(for: "admin/resetPassword") 11 | var requestPath = request.http.urlString 12 | if !requestPath.hasSuffix("/") { 13 | requestPath = requestPath + "/" 14 | } 15 | if user.resetPasswordRequired && requestPath != resetPasswordPath { 16 | let redirect = request.redirect(to: resetPasswordPath) 17 | return request.future(redirect) 18 | } 19 | } catch { 20 | return request.future(request.redirect(to: pathCreator.createPath(for: "admin/login", query: "loginRequired"))) 21 | } 22 | return try next.respond(to: request) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Broken Hands 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/ContextViews/ViewBlogTag.swift: -------------------------------------------------------------------------------- 1 | struct ViewBlogTag: Encodable { 2 | let tagID: Int 3 | let name: String 4 | let urlEncodedName: String 5 | } 6 | 7 | extension BlogTag { 8 | func toViewBlogTag() throws -> ViewBlogTag { 9 | guard let urlEncodedName = self.name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { 10 | throw SteamPressError(identifier: "ViewBlogPost", "Failed to URL encode tag name") 11 | } 12 | guard let tagID = self.tagID else { 13 | throw SteamPressError(identifier: "ViewBlogPost", "Tag has no ID") 14 | } 15 | return ViewBlogTag(tagID: tagID, name: self.name, urlEncodedName: urlEncodedName) 16 | } 17 | } 18 | 19 | extension ViewBlogTag { 20 | 21 | static func percentEncodedTagName(from name: String) throws -> String { 22 | guard let percentEncodedName = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { 23 | throw SteamPressError(identifier: "BlogTag", "Unable to create tag from name \(name)") 24 | } 25 | return percentEncodedName 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SteamPress/Presenters/BlogAdminPresenter.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol BlogAdminPresenter: Service { 4 | func createIndexView(on container: Container, posts: [BlogPost], users: [BlogUser], errors: [String]?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture 5 | func createPostView(on container: Container, errors: [String]?, title: String?, contents: String?, slugURL: String?, tags: [String]?, isEditing: Bool, post: BlogPost?, isDraft: Bool?, titleError: Bool, contentsError: Bool, pageInformation: BlogAdminPageInformation) -> EventLoopFuture 6 | func createUserView(on container: Container, editing: Bool, errors: [String]?, name: String?, nameError: Bool, username: String?, usernameErorr: Bool, passwordError: Bool, confirmPasswordError: Bool, resetPasswordOnLogin: Bool, userID: Int?, profilePicture: String?, twitterHandle: String?, biography: String?, tagline: String?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture 7 | func createResetPasswordView(on container: Container, errors: [String]?, passwordError: Bool?, confirmPasswordError: Bool?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture 8 | } 9 | -------------------------------------------------------------------------------- /Sources/SteamPress/Middleware/BlogAuthSessionsMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public final class BlogAuthSessionsMiddleware: Middleware { 4 | 5 | public init() {} 6 | 7 | public func respond(to request: Request, chainingTo next: Responder) throws -> EventLoopFuture { 8 | let future: EventLoopFuture 9 | if let userIDString = try request.session()["_BlogUserSession"], let userID = Int(userIDString) { 10 | let userRepository = try request.make(BlogUserRepository.self) 11 | future = userRepository.getUser(id: userID, on: request).flatMap { user in 12 | if let user = user { 13 | try request.authenticate(user) 14 | } 15 | return .done(on: request) 16 | } 17 | } else { 18 | future = .done(on: request) 19 | } 20 | 21 | return future.flatMap { 22 | return try next.respond(to: request).map { response in 23 | if let user = try request.authenticated(BlogUser.self) { 24 | try user.authenticateSession(on: request) 25 | } else { 26 | try request.unauthenticateBlogUserSession() 27 | } 28 | return response 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/AdminTests/AdminPageTests.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import XCTest 3 | import SteamPress 4 | 5 | class AdminPageTests: XCTestCase { 6 | 7 | func testAdminPagePassesCorrectInformationToPresenter() throws { 8 | var testWorld = try TestWorld.create() 9 | let user = testWorld.createUser(username: "leia") 10 | let testData1 = try testWorld.createPost(author: user) 11 | let testData2 = try testWorld.createPost(title: "A second post", author: user) 12 | 13 | _ = try testWorld.getResponse(to: "/admin/", loggedInUser: user) 14 | 15 | let presenter = testWorld.context.blogAdminPresenter 16 | XCTAssertNil(presenter.adminViewErrors) 17 | XCTAssertEqual(presenter.adminViewPosts?.count, 2) 18 | XCTAssertEqual(presenter.adminViewPosts?.first?.title, testData2.post.title) 19 | XCTAssertEqual(presenter.adminViewPosts?.last?.title, testData1.post.title) 20 | XCTAssertEqual(presenter.adminViewUsers?.count, 1) 21 | XCTAssertEqual(presenter.adminViewUsers?.last?.username, user.username) 22 | 23 | XCTAssertEqual(presenter.adminViewPageInformation?.loggedInUser.username, user.username) 24 | XCTAssertEqual(presenter.adminViewPageInformation?.websiteURL.absoluteString, "/") 25 | XCTAssertEqual(presenter.adminViewPageInformation?.currentPageURL.absoluteString, "/admin") 26 | 27 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/FeedController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | struct FeedController: RouteCollection { 5 | 6 | // MARK: - Properties 7 | fileprivate let pathCreator: BlogPathCreator 8 | fileprivate let atomGenerator: AtomFeedGenerator 9 | fileprivate let rssGenerator: RSSFeedGenerator 10 | static var defaultTitle: String { 11 | return "SteamPress Blog" 12 | } 13 | static var defaultDescription: String { 14 | return "SteamPress is an open-source blogging engine written for Vapor in Swift" 15 | } 16 | 17 | // MARK: - Initialiser 18 | init(pathCreator: BlogPathCreator, feedInformation: FeedInformation) { 19 | self.pathCreator = pathCreator 20 | 21 | let feedTitle = feedInformation.title ?? FeedController.defaultTitle 22 | let feedDescription = feedInformation.description ?? FeedController.defaultDescription 23 | 24 | atomGenerator = AtomFeedGenerator(title: feedTitle, description: feedDescription, 25 | copyright: feedInformation.copyright, imageURL: feedInformation.imageURL) 26 | rssGenerator = RSSFeedGenerator(title: feedTitle, description: feedDescription, 27 | copyright: feedInformation.copyright, imageURL: feedInformation.imageURL) 28 | } 29 | 30 | // MARK: - Route Collection 31 | func boot(router: Router) throws { 32 | router.get("atom.xml", use: atomGenerator.feedHandler) 33 | router.get("rss.xml", use: rssGenerator.feedHandler) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/BlogUser.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | // MARK: - Model 5 | 6 | public final class BlogUser: Codable { 7 | 8 | public var userID: Int? 9 | public var name: String 10 | public var username: String 11 | public var password: String 12 | public var resetPasswordRequired: Bool = false 13 | public var profilePicture: String? 14 | public var twitterHandle: String? 15 | public var biography: String? 16 | public var tagline: String? 17 | 18 | public init(userID: Int? = nil, name: String, username: String, password: String, profilePicture: String?, twitterHandle: String?, biography: String?, tagline: String?) { 19 | self.userID = userID 20 | self.name = name 21 | self.username = username.lowercased() 22 | self.password = password 23 | self.profilePicture = profilePicture 24 | self.twitterHandle = twitterHandle 25 | self.biography = biography 26 | self.tagline = tagline 27 | } 28 | 29 | } 30 | 31 | // MARK: - Authentication 32 | 33 | extension BlogUser: Authenticatable { 34 | func authenticateSession(on req: Request) throws { 35 | try req.session()["_BlogUserSession"] = self.userID?.description 36 | try req.authenticate(self) 37 | } 38 | } 39 | 40 | extension Request { 41 | func unauthenticateBlogUserSession() throws { 42 | guard try self.hasSession() else { 43 | return 44 | } 45 | try session()["_BlogUserSession"] = nil 46 | try unauthenticate(BlogUser.self) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/TestWorld+TestDataBuilder.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | import Foundation 3 | 4 | extension TestWorld { 5 | func createPost(tags: [String]? = nil, createdDate: Date? = nil, title: String = "An Exciting Post!", contents: String = "This is a blog post", slugUrl: String = "an-exciting-post", author: BlogUser? = nil, published: Bool = true) throws -> TestData { 6 | return try TestDataBuilder.createPost(on: self.context.repository, tags: tags, createdDate: createdDate, title: title, contents: contents, slugUrl: slugUrl, author: author, published: published) 7 | } 8 | 9 | func createPosts(count: Int, author: BlogUser, tag: BlogTag? = nil) throws { 10 | for index in 1...count { 11 | let data = try createPost(title: "Post \(index)", slugUrl: "post-\(index)", author: author) 12 | if let tag = tag { 13 | try context.repository.add(tag, to: data.post) 14 | } 15 | } 16 | } 17 | 18 | func createUser(name: String = "Luke", username: String = "luke", password: String = "password", resetPasswordRequired: Bool = false) -> BlogUser { 19 | let user = TestDataBuilder.anyUser(name: name, username: username, password: password) 20 | self.context.repository.add(user) 21 | if resetPasswordRequired { 22 | user.resetPasswordRequired = true 23 | } 24 | return user 25 | } 26 | 27 | func createTag(_ name: String = "Engineering") throws -> BlogTag { 28 | return try self.context.repository.addTag(name: name) 29 | } 30 | 31 | func createTag(_ name: String = "Engineering", on post: BlogPost) throws -> BlogTag { 32 | return try self.context.repository.addTag(name: name, for: post) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SteamPress/Extensions/URL+Converters.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | 4 | extension Request { 5 | func url() throws -> URL { 6 | let path = self.http.url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" 7 | 8 | let hostname: String 9 | if let envURL = Environment.get("WEBSITE_URL") { 10 | hostname = envURL 11 | } else { 12 | hostname = self.http.remotePeer.description 13 | } 14 | 15 | let urlString = "\(hostname)\(path)" 16 | guard let url = URL(string: urlString) else { 17 | throw SteamPressError(identifier: "SteamPressError", "Failed to convert url path to URL") 18 | } 19 | return url 20 | } 21 | 22 | func rootUrl() throws -> URL { 23 | if let envURL = Environment.get("WEBSITE_URL") { 24 | guard let url = URL(string: envURL) else { 25 | throw SteamPressError(identifier: "SteamPressError", "Failed to convert url hostname to URL") 26 | } 27 | return url 28 | } 29 | 30 | var hostname = self.http.remotePeer.description 31 | if hostname == "" { 32 | hostname = "/" 33 | } 34 | guard let url = URL(string: hostname) else { 35 | throw SteamPressError(identifier: "SteamPressError", "Failed to convert url hostname to URL") 36 | } 37 | return url 38 | } 39 | } 40 | 41 | private extension String { 42 | func replacingFirstOccurrence(of target: String, with replaceString: String) -> String { 43 | if let range = self.range(of: target) { 44 | return self.replacingCharacters(in: range, with: replaceString) 45 | } 46 | return self 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/ProviderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SteamPress 3 | import Vapor 4 | 5 | class ProviderTests: XCTestCase { 6 | func testUsingProviderSetsCorrectServices() throws { 7 | var services = Services.default() 8 | let steampress = SteamPress.Provider() 9 | try services.register(steampress) 10 | 11 | var middlewareConfig = MiddlewareConfig() 12 | middlewareConfig.use(ErrorMiddleware.self) 13 | middlewareConfig.use(BlogRememberMeMiddleware.self) 14 | middlewareConfig.use(SessionsMiddleware.self) 15 | services.register(middlewareConfig) 16 | 17 | services.register([BlogTagRepository.self, BlogPostRepository.self, BlogUserRepository.self]) { _ in 18 | return InMemoryRepository() 19 | } 20 | 21 | let app: Application? = try Application(services: services) 22 | 23 | let numberGenerator = try app!.make(SteamPressRandomNumberGenerator.self) 24 | XCTAssertTrue(type(of: numberGenerator) == RealRandomNumberGenerator.self) 25 | 26 | let blogPresenter = try app!.make(BlogPresenter.self) 27 | XCTAssertTrue(type(of: blogPresenter) == ViewBlogPresenter.self) 28 | 29 | let blogAdminPresenter = try app!.make(BlogAdminPresenter.self) 30 | XCTAssertTrue(type(of: blogAdminPresenter) == ViewBlogAdminPresenter.self) 31 | 32 | // Work around Vapor 3 lifecycle mess 33 | weak var weakApp: Application? = app 34 | weakApp = nil 35 | var tries = 0 36 | while weakApp != nil && tries < 10 { 37 | Thread.sleep(forTimeInterval: 0.1) 38 | tries += 1 39 | } 40 | XCTAssertNil(weakApp, "application leak: \(weakApp.debugDescription)") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SteamPress/Presenters/BlogPresenter.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol BlogPresenter: Service { 4 | func indexView(on container: Container, posts: [BlogPost], tags: [BlogTag], authors: [BlogUser], tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture 5 | func postView(on container: Container, post: BlogPost, author: BlogUser, tags: [BlogTag], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture 6 | func allAuthorsView(on container: Container, authors: [BlogUser], authorPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture 7 | func authorView(on container: Container, author: BlogUser, posts: [BlogPost], postCount: Int, tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture 8 | func allTagsView(on container: Container, tags: [BlogTag], tagPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture 9 | func tagView(on container: Container, tag: BlogTag, posts: [BlogPost], authors: [BlogUser], totalPosts: Int, pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture 10 | func searchView(on container: Container, totalResults: Int, posts: [BlogPost], authors: [BlogUser], searchTerm: String?, tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture 11 | func loginView(on container: Container, loginWarning: Bool, errors: [String]?, username: String?, usernameError: Bool, passwordError: Bool, rememberMe: Bool, pageInformation: BlogGlobalPageInformation) -> EventLoopFuture 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/BlogAdminController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | struct BlogAdminController: RouteCollection { 5 | 6 | // MARK: - Properties 7 | fileprivate let pathCreator: BlogPathCreator 8 | 9 | // MARK: - Initialiser 10 | init(pathCreator: BlogPathCreator) { 11 | self.pathCreator = pathCreator 12 | } 13 | 14 | // MARK: - Route setup 15 | func boot(router: Router) throws { 16 | let adminRoutes = router.grouped("admin") 17 | 18 | let redirectMiddleware = BlogLoginRedirectAuthMiddleware(pathCreator: pathCreator) 19 | let adminProtectedRoutes = adminRoutes.grouped(redirectMiddleware) 20 | adminProtectedRoutes.get(use: adminHandler) 21 | 22 | let loginController = LoginController(pathCreator: pathCreator) 23 | try adminRoutes.register(collection: loginController) 24 | let postController = PostAdminController(pathCreator: pathCreator) 25 | try adminProtectedRoutes.register(collection: postController) 26 | let userController = UserAdminController(pathCreator: pathCreator) 27 | try adminProtectedRoutes.register(collection: userController) 28 | } 29 | 30 | // MARK: Admin Handler 31 | func adminHandler(_ req: Request) throws -> EventLoopFuture { 32 | let usersRepository = try req.make(BlogUserRepository.self) 33 | let postsRepository = try req.make(BlogPostRepository.self) 34 | return flatMap(postsRepository.getAllPostsSortedByPublishDate(includeDrafts: true, on: req), usersRepository.getAllUsers(on: req)) { posts, users in 35 | let presenter = try req.make(BlogAdminPresenter.self) 36 | return try presenter.createIndexView(on: req, posts: posts, users: users, errors: nil, pageInformation: req.adminPageInfomation()) 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/TestWorld.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | import Vapor 3 | 4 | struct TestWorld { 5 | 6 | static func create(path: String? = nil, postsPerPage: Int = 10, feedInformation: FeedInformation = FeedInformation(), enableAuthorPages: Bool = true, enableTagPages: Bool = true, passwordHasherToUse: PasswordHasherChoice = .plaintext, randomNumberGenerator: StubbedRandomNumberGenerator = StubbedRandomNumberGenerator(numberToReturn: 666)) throws -> TestWorld { 7 | let repository = InMemoryRepository() 8 | let blogPresenter = CapturingBlogPresenter() 9 | let blogAdminPresenter = CapturingAdminPresenter() 10 | let application = try TestWorld.getSteamPressApp(repository: repository, path: path, postsPerPage: postsPerPage, feedInformation: feedInformation, blogPresenter: blogPresenter, adminPresenter: blogAdminPresenter, enableAuthorPages: enableAuthorPages, enableTagPages: enableTagPages, passwordHasherToUse: passwordHasherToUse, randomNumberGenerator: randomNumberGenerator) 11 | let context = Context(app: application, repository: repository, blogPresenter: blogPresenter, blogAdminPresenter: blogAdminPresenter, path: path) 12 | unsetenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER") 13 | unsetenv("BLOG_SITE_TWITTER_HANDLE") 14 | unsetenv("BLOG_DISQUS_NAME") 15 | unsetenv("WEBSITE_URL") 16 | return TestWorld(context: context) 17 | } 18 | 19 | var context: Context 20 | 21 | init(context: Context) { 22 | self.context = context 23 | } 24 | 25 | struct Context { 26 | var app: Application? 27 | let repository: InMemoryRepository 28 | let blogPresenter: CapturingBlogPresenter 29 | let blogAdminPresenter: CapturingAdminPresenter 30 | let path: String? 31 | } 32 | 33 | // To work around Vapor 3 dodgy lifecycle mess 34 | mutating func tryAsHardAsWeCanToShutdownApplication() throws { 35 | struct ApplicationDidNotGoAway: Error { 36 | var description: String 37 | } 38 | weak var weakApp: Application? = context.app 39 | context.app = nil 40 | var tries = 0 41 | while weakApp != nil && tries < 10 { 42 | Thread.sleep(forTimeInterval: 0.1) 43 | tries += 1 44 | } 45 | if weakApp != nil { 46 | throw ApplicationDidNotGoAway(description: "application leak: \(weakApp.debugDescription)") 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/TestWorld+Responses.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | @testable import SteamPress 3 | 4 | extension TestWorld { 5 | func getResponse(to path: String, method: HTTPMethod = .GET, headers: HTTPHeaders = .init(), decodeTo type: T.Type) throws -> T where T: Content { 6 | let response = try getResponse(to: path, method: method, headers: headers) 7 | return try response.content.decode(type).wait() 8 | } 9 | 10 | func getResponseString(to path: String, headers: HTTPHeaders = .init()) throws -> String { 11 | let data = try getResponse(to: path, headers: headers).http.body.convertToHTTPBody().data 12 | return String(data: data!, encoding: .utf8)! 13 | } 14 | 15 | func getResponse(to path: String, method: HTTPMethod = .POST, body: T, loggedInUser: BlogUser? = nil, passwordToLoginWith: String? = nil, headers: HTTPHeaders = .init()) throws -> Response { 16 | let request = try setupRequest(to: path, method: method, loggedInUser: loggedInUser, passwordToLoginWith: passwordToLoginWith, headers: headers) 17 | try request.content.encode(body) 18 | return try getResponse(to: request) 19 | } 20 | 21 | func getResponse(to path: String, method: HTTPMethod = .GET, headers: HTTPHeaders = .init(), loggedInUser: BlogUser? = nil) throws -> Response { 22 | let request = try setupRequest(to: path, method: method, loggedInUser: loggedInUser, passwordToLoginWith: nil, headers: headers) 23 | return try getResponse(to: request) 24 | } 25 | 26 | func setupRequest(to path: String, method: HTTPMethod = .POST, loggedInUser: BlogUser? = nil, passwordToLoginWith: String? = nil, headers: HTTPHeaders = .init()) throws -> Request { 27 | var request = HTTPRequest(method: method, url: URL(string: path)!, headers: headers) 28 | request.cookies["steampress-session"] = try setLoginCookie(for: loggedInUser, password: passwordToLoginWith) 29 | 30 | guard let app = context.app else { 31 | fatalError("App has already been deinitiliased") 32 | } 33 | return Request(http: request, using: app) 34 | } 35 | 36 | func setLoginCookie(for user: BlogUser?, password: String? = nil) throws -> HTTPCookieValue? { 37 | if let user = user { 38 | let loginData = LoginData(username: user.username, password: password ?? user.password) 39 | var loginPath = "/admin/login" 40 | if let path = context.path { 41 | loginPath = "/\(path)\(loginPath)" 42 | } 43 | let loginResponse = try getResponse(to: loginPath, method: .POST, body: loginData) 44 | let sessionCookie = loginResponse.http.cookies["steampress-session"] 45 | return sessionCookie 46 | } else { 47 | return nil 48 | } 49 | } 50 | 51 | func getResponse(to request: Request) throws -> Response { 52 | guard let app = context.app else { 53 | fatalError("App has already been deinitiliased") 54 | } 55 | let responder = try app.make(Responder.self) 56 | return try responder.respond(to: request).wait() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/BlogPost.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | import SwiftSoup 4 | import SwiftMarkdown 5 | 6 | // MARK: - Model 7 | 8 | public final class BlogPost: Codable { 9 | 10 | public var blogID: Int? 11 | public var title: String 12 | public var contents: String 13 | public var author: Int 14 | public var created: Date 15 | public var lastEdited: Date? 16 | public var slugUrl: String 17 | public var published: Bool 18 | 19 | public init(title: String, contents: String, author: BlogUser, creationDate: Date, slugUrl: String, 20 | published: Bool) throws { 21 | self.title = title 22 | self.contents = contents 23 | guard let authorID = author.userID else { 24 | throw SteamPressError(identifier: "ID-required", "Author ID not set") 25 | } 26 | self.author = authorID 27 | self.created = creationDate 28 | self.slugUrl = slugUrl 29 | self.lastEdited = nil 30 | self.published = published 31 | } 32 | } 33 | 34 | // MARK: - BlogPost Utilities 35 | 36 | extension BlogPost { 37 | 38 | public func shortSnippet() -> String { 39 | return getLines(characterLimit: 150) 40 | } 41 | 42 | public func longSnippet() -> String { 43 | return getLines(characterLimit: 900) 44 | } 45 | 46 | func description() throws -> String { 47 | return try SwiftSoup.parse(markdownToHTML(shortSnippet())).text() 48 | } 49 | 50 | private func getLines(characterLimit: Int) -> String { 51 | contents = contents.replacingOccurrences(of: "\r\n", with: "\n", options: .regularExpression) 52 | let lines = contents.components(separatedBy: "\n") 53 | var snippet = "" 54 | for line in lines where line != "" { 55 | snippet += "\(line)\n" 56 | if snippet.count > characterLimit { 57 | return snippet 58 | } 59 | } 60 | return snippet 61 | } 62 | 63 | static func generateUniqueSlugURL(from title: String, on req: Request) throws -> EventLoopFuture { 64 | let postRepository = try req.make(BlogPostRepository.self) 65 | let alphanumericsWithHyphenAndSpace = CharacterSet(charactersIn: " -0123456789abcdefghijklmnopqrstuvwxyz") 66 | let initialSlug = title.lowercased() 67 | .trimmingCharacters(in: .whitespacesAndNewlines) 68 | .components(separatedBy: alphanumericsWithHyphenAndSpace.inverted).joined() 69 | .components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ") 70 | .replacingOccurrences(of: " ", with: "-", options: .regularExpression) 71 | return postRepository.getPost(slug: initialSlug, on: req).map { postWithSameSlug in 72 | if postWithSameSlug != nil { 73 | let randomNumberGenerator = try req.make(SteamPressRandomNumberGenerator.self) 74 | let randomNumber = randomNumberGenerator.getNumber() 75 | return "\(initialSlug)-\(randomNumber)" 76 | } else { 77 | return initialSlug 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@brokenhands.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/TestWorld+Application.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | import Vapor 3 | import Authentication 4 | 5 | extension TestWorld { 6 | static func getSteamPressApp(repository: InMemoryRepository, 7 | path: String?, 8 | postsPerPage: Int, 9 | feedInformation: FeedInformation, 10 | blogPresenter: CapturingBlogPresenter, 11 | adminPresenter: CapturingAdminPresenter, 12 | enableAuthorPages: Bool, 13 | enableTagPages: Bool, 14 | passwordHasherToUse: PasswordHasherChoice, 15 | randomNumberGenerator: StubbedRandomNumberGenerator) throws -> Application { 16 | var services = Services.default() 17 | let steampress = SteamPress.Provider( 18 | blogPath: path, 19 | feedInformation: feedInformation, 20 | postsPerPage: postsPerPage, 21 | enableAuthorPages: enableAuthorPages, 22 | enableTagPages: enableTagPages) 23 | try services.register(steampress) 24 | 25 | services.register([BlogTagRepository.self, BlogPostRepository.self, BlogUserRepository.self]) { _ in 26 | return repository 27 | } 28 | 29 | services.register(SteamPressRandomNumberGenerator.self) { _ in 30 | return randomNumberGenerator 31 | } 32 | 33 | services.register(BlogPresenter.self) { _ in 34 | return blogPresenter 35 | } 36 | 37 | services.register(BlogAdminPresenter.self) { _ in 38 | return adminPresenter 39 | } 40 | 41 | var middlewareConfig = MiddlewareConfig() 42 | middlewareConfig.use(ErrorMiddleware.self) 43 | middlewareConfig.use(BlogRememberMeMiddleware.self) 44 | middlewareConfig.use(SessionsMiddleware.self) 45 | services.register(middlewareConfig) 46 | 47 | var config = Config.default() 48 | 49 | config.prefer(CapturingBlogPresenter.self, for: BlogPresenter.self) 50 | config.prefer(CapturingAdminPresenter.self, for: BlogAdminPresenter.self) 51 | config.prefer(StubbedRandomNumberGenerator.self, for: SteamPressRandomNumberGenerator.self) 52 | 53 | switch passwordHasherToUse { 54 | case .real: 55 | config.prefer(BCryptDigest.self, for: PasswordVerifier.self) 56 | config.prefer(BCryptDigest.self, for: PasswordHasher.self) 57 | case .plaintext: 58 | services.register(PasswordHasher.self) { _ in 59 | return PlaintextHasher() 60 | } 61 | config.prefer(PlaintextVerifier.self, for: PasswordVerifier.self) 62 | config.prefer(PlaintextHasher.self, for: PasswordHasher.self) 63 | case .reversed: 64 | services.register([PasswordHasher.self, PasswordVerifier.self]) { _ in 65 | return ReversedPasswordHasher() 66 | } 67 | config.prefer(ReversedPasswordHasher.self, for: PasswordVerifier.self) 68 | config.prefer(ReversedPasswordHasher.self, for: PasswordHasher.self) 69 | } 70 | 71 | return try Application(config: config, services: services) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/PostTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SteamPress 3 | import Vapor 4 | import Foundation 5 | 6 | class PostTests: XCTestCase { 7 | 8 | // MARK: - Properties 9 | var testWorld: TestWorld! 10 | var firstData: TestData! 11 | private let blogPostPath = "/posts/test-path" 12 | 13 | var presenter: CapturingBlogPresenter { 14 | return testWorld.context.blogPresenter 15 | } 16 | 17 | // MARK: - Overrides 18 | 19 | override func setUp() { 20 | testWorld = try! TestWorld.create() 21 | firstData = try! testWorld.createPost(title: "Test Path", slugUrl: "test-path") 22 | } 23 | 24 | override func tearDown() { 25 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 26 | } 27 | 28 | // MARK: - Tests 29 | 30 | func testBlogPostRetrievedCorrectlyFromSlugUrl() throws { 31 | _ = try testWorld.getResponse(to: blogPostPath) 32 | 33 | XCTAssertEqual(presenter.post?.title, firstData.post.title) 34 | XCTAssertEqual(presenter.post?.contents, firstData.post.contents) 35 | XCTAssertEqual(presenter.postAuthor?.name, firstData.author.name) 36 | XCTAssertEqual(presenter.postAuthor?.username, firstData.author.username) 37 | } 38 | 39 | func testPostPageGetsCorrectPageInformation() throws { 40 | _ = try testWorld.getResponse(to: blogPostPath) 41 | XCTAssertNil(presenter.postPageInformation?.disqusName) 42 | XCTAssertNil(presenter.postPageInformation?.googleAnalyticsIdentifier) 43 | XCTAssertNil(presenter.postPageInformation?.siteTwitterHandle) 44 | XCTAssertNil(presenter.postPageInformation?.loggedInUser) 45 | XCTAssertEqual(presenter.postPageInformation?.currentPageURL.absoluteString, blogPostPath) 46 | XCTAssertEqual(presenter.postPageInformation?.websiteURL.absoluteString, "/") 47 | } 48 | 49 | func testPostPageInformationGetsLoggedInUser() throws { 50 | _ = try testWorld.getResponse(to: blogPostPath, loggedInUser: firstData.author) 51 | XCTAssertEqual(presenter.postPageInformation?.loggedInUser?.username, firstData.author.username) 52 | } 53 | 54 | func testSettingEnvVarsWithPageInformation() throws { 55 | let googleAnalytics = "ABDJIODJWOIJIWO" 56 | let twitterHandle = "3483209fheihgifffe" 57 | let disqusName = "34829u48932fgvfbrtewerg" 58 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 59 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 60 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 61 | _ = try testWorld.getResponse(to: blogPostPath) 62 | XCTAssertEqual(presenter.postPageInformation?.disqusName, disqusName) 63 | XCTAssertEqual(presenter.postPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 64 | XCTAssertEqual(presenter.postPageInformation?.siteTwitterHandle, twitterHandle) 65 | } 66 | 67 | func testPostPageGetsTags() throws { 68 | let tag1Name = "Something" 69 | let tag2Name = "Something else" 70 | _ = try testWorld.createTag(tag1Name, on: firstData.post) 71 | _ = try testWorld.createTag(tag2Name, on: firstData.post) 72 | 73 | _ = try testWorld.getResponse(to: blogPostPath) 74 | 75 | let tags = try XCTUnwrap(presenter.postPageTags) 76 | XCTAssertEqual(tags.count, 2) 77 | XCTAssertEqual(tags.first?.name, tag1Name) 78 | XCTAssertEqual(tags.last?.name, tag2Name) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/SteamPress/Provider.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | public struct Provider: Vapor.Provider { 5 | 6 | let blogPath: String? 7 | let feedInformation: FeedInformation 8 | let postsPerPage: Int 9 | let enableAuthorPages: Bool 10 | let enableTagPages: Bool 11 | let pathCreator: BlogPathCreator 12 | 13 | /** 14 | Initialiser for SteamPress' Provider to add a blog to your Vapor App. You can pass it an optional 15 | `blogPath` to add the blog to. For instance, if you pass in "blog", your blog will be accessible 16 | at http://mysite.com/blog/, or if you pass in `nil` your blog will be added to the root of your 17 | site (i.e. http://mysite.com/) 18 | - Parameter blogPath: The path to add the blog to (see above). 19 | - Parameter feedInformation: Information to vend to the RSS and Atom feeds. Defaults to empty information. 20 | - Parameter postsPerPage: The number of posts to show per page on the main index page of the blog. Defaults to 10. 21 | - Parameter enableAuthorsPages: Flag used to determine whether to publicly expose the authors endpoints 22 | or not. Defaults to true. 23 | - Parameter enableTagsPages: Flag used to determine whether to publicy expose the tags endpoints or not. 24 | Defaults to true. 25 | */ 26 | public init( 27 | blogPath: String? = nil, 28 | feedInformation: FeedInformation = FeedInformation(), 29 | postsPerPage: Int = 10, 30 | enableAuthorPages: Bool = true, 31 | enableTagPages: Bool = true) { 32 | self.blogPath = blogPath 33 | self.feedInformation = feedInformation 34 | self.postsPerPage = postsPerPage 35 | self.enableAuthorPages = enableAuthorPages 36 | self.enableTagPages = enableTagPages 37 | self.pathCreator = BlogPathCreator(blogPath: self.blogPath) 38 | } 39 | 40 | public func register(_ services: inout Services) throws { 41 | services.register(BlogPresenter.self) { _ in 42 | return ViewBlogPresenter() 43 | } 44 | 45 | services.register(BlogAdminPresenter.self) { _ in 46 | return ViewBlogAdminPresenter(pathCreator: self.pathCreator) 47 | } 48 | 49 | try services.register(AuthenticationProvider()) 50 | services.register([PasswordHasher.self, PasswordVerifier.self]) { _ in 51 | return BCryptDigest() 52 | } 53 | services.register(SteamPressRandomNumberGenerator.self) { _ in 54 | return RealRandomNumberGenerator() 55 | } 56 | 57 | services.register(BlogRememberMeMiddleware.self) 58 | services.register(LongPostDateFormatter.self) 59 | services.register(NumericPostDateFormatter.self) 60 | } 61 | 62 | public func willBoot(_ container: Container) throws -> EventLoopFuture { 63 | let router = try container.make(Router.self) 64 | 65 | let feedController = FeedController(pathCreator: self.pathCreator, feedInformation: self.feedInformation) 66 | let apiController = APIController() 67 | let blogController = BlogController(pathCreator: self.pathCreator, enableAuthorPages: self.enableAuthorPages, enableTagPages: self.enableTagPages, postsPerPage: self.postsPerPage) 68 | let blogAdminController = BlogAdminController(pathCreator: self.pathCreator) 69 | 70 | let blogRoutes: Router 71 | if let blogPath = blogPath { 72 | blogRoutes = router.grouped(blogPath) 73 | } else { 74 | blogRoutes = router.grouped("") 75 | } 76 | let steampressSessionsConfig = SessionsConfig(cookieName: "steampress-session") { value in 77 | return HTTPCookieValue(string: value) 78 | } 79 | let steampressSessions = try SessionsMiddleware(sessions: container.make(), config: steampressSessionsConfig) 80 | let steampressAuthSessions = BlogAuthSessionsMiddleware() 81 | let sessionedRoutes = blogRoutes.grouped(steampressSessions, steampressAuthSessions) 82 | 83 | try sessionedRoutes.register(collection: feedController) 84 | try sessionedRoutes.register(collection: apiController) 85 | try sessionedRoutes.register(collection: blogController) 86 | try sessionedRoutes.register(collection: blogAdminController) 87 | return .done(on: container) 88 | } 89 | 90 | public func didBoot(_ container: Container) throws -> EventLoopFuture { 91 | return .done(on: container) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/SteamPress/Presenters/ViewBlogAdminPresenter.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public struct ViewBlogAdminPresenter: BlogAdminPresenter { 4 | 5 | let pathCreator: BlogPathCreator 6 | 7 | public func createIndexView(on container: Container, posts: [BlogPost], users: [BlogUser], errors: [String]?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 8 | do { 9 | let viewRenderer = try container.make(ViewRenderer.self) 10 | let publishedPosts = try posts.filter { $0.published }.convertToViewBlogPostsWithoutTags(authors: users, on: container) 11 | let draftPosts = try posts.filter { !$0.published }.convertToViewBlogPostsWithoutTags(authors: users, on: container) 12 | let context = AdminPageContext(errors: errors, publishedPosts: publishedPosts, draftPosts: draftPosts, users: users, pageInformation: pageInformation) 13 | return viewRenderer.render("blog/admin/index", context) 14 | } catch { 15 | return container.future(error: error) 16 | } 17 | } 18 | 19 | public func createPostView(on container: Container, errors: [String]?, title: String?, contents: String?, slugURL: String?, tags: [String]?, isEditing: Bool, post: BlogPost?, isDraft: Bool?, titleError: Bool, contentsError: Bool, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 20 | do { 21 | if isEditing { 22 | guard post != nil else { 23 | return container.future(error: SteamPressError(identifier: "ViewBlogAdminPresenter", "Blog Post is empty whilst editing")) 24 | } 25 | } 26 | let viewRenderer = try container.make(ViewRenderer.self) 27 | let postPathSuffix = pathCreator.createPath(for: "posts") 28 | let postPathPrefix = pageInformation.websiteURL.appendingPathComponent(postPathSuffix) 29 | let pageTitle = isEditing ? "Edit Blog Post" : "Create Blog Post" 30 | let context = CreatePostPageContext(title: pageTitle, editing: isEditing, post: post, draft: isDraft ?? false, errors: errors, titleSupplied: title, contentsSupplied: contents, tagsSupplied: tags, slugURLSupplied: slugURL, titleError: titleError, contentsError: contentsError, postPathPrefix: postPathPrefix.absoluteString, pageInformation: pageInformation) 31 | return viewRenderer.render("blog/admin/createPost", context) 32 | } catch { 33 | return container.future(error: error) 34 | } 35 | } 36 | 37 | public func createUserView(on container: Container, editing: Bool, errors: [String]?, name: String?, nameError: Bool, username: String?, usernameErorr: Bool, passwordError: Bool, confirmPasswordError: Bool, resetPasswordOnLogin: Bool, userID: Int?, profilePicture: String?, twitterHandle: String?, biography: String?, tagline: String?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 38 | do { 39 | if editing { 40 | guard userID != nil else { 41 | return container.future(error: SteamPressError(identifier: "ViewBlogAdminPresenter", "User ID is nil whilst editing")) 42 | } 43 | } 44 | 45 | let viewRenderer = try container.make(ViewRenderer.self) 46 | let context = CreateUserPageContext(editing: editing, errors: errors, nameSupplied: name, nameError: nameError, usernameSupplied: username, usernameError: usernameErorr, passwordError: passwordError, confirmPasswordError: confirmPasswordError, resetPasswordOnLoginSupplied: resetPasswordOnLogin, userID: userID, twitterHandleSupplied: twitterHandle, profilePictureSupplied: profilePicture, biographySupplied: biography, taglineSupplied: tagline, pageInformation: pageInformation) 47 | return viewRenderer.render("blog/admin/createUser", context) 48 | } catch { 49 | return container.future(error: error) 50 | } 51 | } 52 | 53 | public func createResetPasswordView(on container: Container, errors: [String]?, passwordError: Bool?, confirmPasswordError: Bool?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 54 | do { 55 | let viewRenderer = try container.make(ViewRenderer.self) 56 | let context = ResetPasswordPageContext(errors: errors, passwordError: passwordError, confirmPasswordError: confirmPasswordError, pageInformation: pageInformation) 57 | return viewRenderer.render("blog/admin/resetPassword", context) 58 | } catch { 59 | return container.future(error: error) 60 | } 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SteamPress/Feed Generators/RSSFeedGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | struct RSSFeedGenerator { 5 | 6 | // MARK: - Properties 7 | 8 | let rfc822DateFormatter: DateFormatter 9 | let title: String 10 | let description: String 11 | let copyright: String? 12 | let imageURL: String? 13 | let xmlEnd = "\n\n" 14 | 15 | // MARK: - Initialiser 16 | 17 | init(title: String, description: String, copyright: String?, imageURL: String?) { 18 | self.title = title 19 | self.description = description 20 | self.copyright = copyright 21 | self.imageURL = imageURL 22 | 23 | rfc822DateFormatter = DateFormatter() 24 | rfc822DateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" 25 | rfc822DateFormatter.locale = Locale(identifier: "en_US_POSIX") 26 | rfc822DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 27 | } 28 | 29 | // MARK: - Route Handler 30 | 31 | func feedHandler(_ request: Request) throws -> EventLoopFuture { 32 | 33 | let blogRepository = try request.make(BlogPostRepository.self) 34 | return blogRepository.getAllPostsSortedByPublishDate(includeDrafts: false, on: request).flatMap { posts in 35 | var xmlFeed = self.getXMLStart(for: request) 36 | 37 | if !posts.isEmpty { 38 | let postDate = posts[0].lastEdited ?? posts[0].created 39 | xmlFeed += "\(self.rfc822DateFormatter.string(from: postDate))\n" 40 | } 41 | 42 | xmlFeed += "\nSearch \(self.title)\nSearch\n\(self.getRootPath(for: request))/search?\nterm\n\n" 43 | 44 | var postData: [EventLoopFuture] = [] 45 | for post in posts { 46 | try postData.append(post.getPostRSSFeed(rootPath: self.getRootPath(for: request), dateFormatter: self.rfc822DateFormatter, for: request)) 47 | } 48 | 49 | return postData.flatten(on: request).map { postInformation in 50 | for post in postInformation { 51 | xmlFeed += post 52 | } 53 | 54 | xmlFeed += self.xmlEnd 55 | var httpResponse = HTTPResponse(body: xmlFeed) 56 | httpResponse.headers.add(name: .contentType, value: "application/rss+xml") 57 | return httpResponse 58 | } 59 | } 60 | } 61 | 62 | // MARK: - Private functions 63 | 64 | private func getXMLStart(for request: Request) -> String { 65 | 66 | let link = getRootPath(for: request) + "/" 67 | 68 | var start = "\n\n\n\n\(title)\n\(link)\n\(description)\nSteamPress\n60\n" 69 | 70 | if let copyright = copyright { 71 | start += "\(copyright)\n" 72 | } 73 | 74 | if let imageURL = imageURL { 75 | start += "\n\(imageURL)\n\(title)\n\(link)\n\n" 76 | } 77 | 78 | return start 79 | } 80 | 81 | private func getRootPath(for request: Request) -> String { 82 | let hostname = request.http.remotePeer.description 83 | let path = request.http.url.path 84 | return "\(hostname)\(path.replacingOccurrences(of: "/rss.xml", with: ""))" 85 | } 86 | } 87 | 88 | fileprivate extension BlogPost { 89 | func getPostRSSFeed(rootPath: String, dateFormatter: DateFormatter, for request: Request) throws -> EventLoopFuture { 90 | let link = rootPath + "/posts/\(slugUrl)/" 91 | var postEntry = "\n\n\(title)\n\n\n\(try description())\n\n\n\(link)\n\n" 92 | 93 | let tagRepository = try request.make(BlogTagRepository.self) 94 | return tagRepository.getTags(for: self, on: request).map { tags in 95 | for tag in tags { 96 | if let percentDecodedTag = tag.name.removingPercentEncoding { 97 | postEntry += "\(percentDecodedTag)\n" 98 | } 99 | } 100 | postEntry += "\(dateFormatter.string(from: self.lastEdited ?? self.created))\n\n" 101 | return postEntry 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/AdminTests/AccessControlTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | import SteamPress 4 | 5 | class AccessControlTests: XCTestCase { 6 | 7 | // MARK: - Properties 8 | private var app: Application! 9 | private var testWorld: TestWorld! 10 | private var user: BlogUser! 11 | 12 | // MARK: - Overrides 13 | 14 | override func setUp() { 15 | testWorld = try! TestWorld.create(path: "blog") 16 | user = testWorld.createUser() 17 | } 18 | 19 | override func tearDown() { 20 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 21 | } 22 | 23 | // MARK: - Tests 24 | 25 | // MARK: - Access restriction tests 26 | 27 | func testCannotAccessAdminPageWithoutBeingLoggedIn() throws { 28 | try assertLoginRequired(method: .GET, path: "") 29 | } 30 | 31 | func testCannotAccessCreateBlogPostPageWithoutBeingLoggedIn() throws { 32 | try assertLoginRequired(method: .GET, path: "createPost") 33 | } 34 | 35 | func testCannotSendCreateBlogPostPageWithoutBeingLoggedIn() throws { 36 | try assertLoginRequired(method: .POST, path: "createPost") 37 | } 38 | 39 | func testCannotAccessEditPostPageWithoutLogin() throws { 40 | let post = try testWorld.createPost() 41 | try assertLoginRequired(method: .GET, path: "posts/\(post.post.blogID!)/edit") 42 | } 43 | 44 | func testCannotSendEditPostPageWithoutLogin() throws { 45 | let post = try testWorld.createPost() 46 | try assertLoginRequired(method: .POST, path: "posts/\(post.post.blogID!)/edit") 47 | } 48 | 49 | func testCannotAccessCreateUserPageWithoutLogin() throws { 50 | try assertLoginRequired(method: .GET, path: "createUser") 51 | } 52 | 53 | func testCannotSendCreateUserPageWithoutLogin() throws { 54 | try assertLoginRequired(method: .POST, path: "createUser") 55 | } 56 | 57 | func testCannotAccessEditUserPageWithoutLogin() throws { 58 | try assertLoginRequired(method: .GET, path: "users/1/edit") 59 | } 60 | 61 | func testCannotSendEditUserPageWithoutLogin() throws { 62 | try assertLoginRequired(method: .POST, path: "users/1/edit") 63 | } 64 | 65 | func testCannotDeletePostWithoutLogin() throws { 66 | try assertLoginRequired(method: .POST, path: "posts/1/delete") 67 | } 68 | 69 | func testCannotDeleteUserWithoutLogin() throws { 70 | try assertLoginRequired(method: .POST, path: "users/1/delete") 71 | } 72 | 73 | func testCannotAccessResetPasswordPageWithoutLogin() throws { 74 | try assertLoginRequired(method: .GET, path: "resetPassword") 75 | } 76 | 77 | func testCannotSendResetPasswordPageWithoutLogin() throws { 78 | try assertLoginRequired(method: .POST, path: "resetPassword") 79 | } 80 | 81 | // MARK: - Access Success Tests 82 | 83 | func testCanAccessAdminPageWhenLoggedIn() throws { 84 | let response = try testWorld.getResponse(to: "/blog/admin/", loggedInUser: user) 85 | XCTAssertEqual(response.http.status, .ok) 86 | } 87 | 88 | func testCanAccessCreatePostPageWhenLoggedIn() throws { 89 | let response = try testWorld.getResponse(to: "/blog/admin/createPost", loggedInUser: user) 90 | XCTAssertEqual(response.http.status, .ok) 91 | } 92 | 93 | func testCanAccessEditPostPageWhenLoggedIn() throws { 94 | let post = try testWorld.createPost() 95 | let response = try testWorld.getResponse(to: "/blog/admin/posts/\(post.post.blogID!)/edit", loggedInUser: user) 96 | XCTAssertEqual(response.http.status, .ok) 97 | } 98 | 99 | func testCanAccessCreateUserPageWhenLoggedIn() throws { 100 | let response = try testWorld.getResponse(to: "/blog/admin/createUser", loggedInUser: user) 101 | XCTAssertEqual(response.http.status, .ok) 102 | } 103 | 104 | func testCanAccessEditUserPageWhenLoggedIn() throws { 105 | let response = try testWorld.getResponse(to: "/blog/admin/users/1/edit", loggedInUser: user) 106 | XCTAssertEqual(response.http.status, .ok) 107 | } 108 | 109 | func testCanAccessResetPasswordPage() throws { 110 | let response = try testWorld.getResponse(to: "/blog/admin/resetPassword", loggedInUser: user) 111 | XCTAssertEqual(response.http.status, .ok) 112 | } 113 | 114 | // MARK: - Helpers 115 | 116 | private func assertLoginRequired(method: HTTPMethod, path: String) throws { 117 | let response = try testWorld.getResponse(to: "/blog/admin/\(path)", method: method) 118 | 119 | XCTAssertEqual(response.http.status, .seeOther) 120 | XCTAssertEqual(response.http.headers[.location].first, "/blog/admin/login/?loginRequired") 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /Sources/SteamPress/Repositories/SteamPressRepository.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public protocol BlogTagRepository { 4 | func getAllTags(on container: Container) -> EventLoopFuture<[BlogTag]> 5 | func getAllTagsWithPostCount(on container: Container) -> EventLoopFuture<[(BlogTag, Int)]> 6 | func getTags(for post: BlogPost, on container: Container) -> EventLoopFuture<[BlogTag]> 7 | func getTagsForAllPosts(on container: Container) -> EventLoopFuture<[Int: [BlogTag]]> 8 | func getTag(_ name: String, on container: Container) -> EventLoopFuture 9 | func save(_ tag: BlogTag, on container: Container) -> EventLoopFuture 10 | // Delete all the pivots between a post and collection of tags - you should probably delete the 11 | // tags that have no posts associated with a tag 12 | func deleteTags(for post: BlogPost, on container: Container) -> EventLoopFuture 13 | func remove(_ tag: BlogTag, from post: BlogPost, on container: Container) -> EventLoopFuture 14 | func add(_ tag: BlogTag, to post: BlogPost, on container: Container) -> EventLoopFuture 15 | } 16 | 17 | public protocol BlogPostRepository { 18 | func getAllPostsSortedByPublishDate(includeDrafts: Bool, on container: Container) -> EventLoopFuture<[BlogPost]> 19 | func getAllPostsCount(includeDrafts: Bool, on container: Container) -> EventLoopFuture 20 | func getAllPostsSortedByPublishDate(includeDrafts: Bool, on container: Container, count: Int, offset: Int) -> EventLoopFuture<[BlogPost]> 21 | func getAllPostsSortedByPublishDate(for user: BlogUser, includeDrafts: Bool, on container: Container, count: Int, offset: Int) -> EventLoopFuture<[BlogPost]> 22 | func getPostCount(for user: BlogUser, on container: Container) -> EventLoopFuture 23 | func getPost(slug: String, on container: Container) -> EventLoopFuture 24 | func getPost(id: Int, on container: Container) -> EventLoopFuture 25 | func getSortedPublishedPosts(for tag: BlogTag, on container: Container, count: Int, offset: Int) -> EventLoopFuture<[BlogPost]> 26 | func getPublishedPostCount(for tag: BlogTag, on container: Container) -> EventLoopFuture 27 | func findPublishedPostsOrdered(for searchTerm: String, on container: Container, count: Int, offset: Int) -> EventLoopFuture<[BlogPost]> 28 | func getPublishedPostCount(for searchTerm: String, on container: Container) -> EventLoopFuture 29 | func save(_ post: BlogPost, on container: Container) -> EventLoopFuture 30 | func delete(_ post: BlogPost, on container: Container) -> EventLoopFuture 31 | } 32 | 33 | public protocol BlogUserRepository { 34 | func getAllUsers(on container: Container) -> EventLoopFuture<[BlogUser]> 35 | func getAllUsersWithPostCount(on container: Container) -> EventLoopFuture<[(BlogUser, Int)]> 36 | func getUser(id: Int, on container: Container) -> EventLoopFuture 37 | func getUser(username: String, on container: Container) -> EventLoopFuture 38 | func save(_ user: BlogUser, on container: Container) -> EventLoopFuture 39 | func delete(_ user: BlogUser, on container: Container) -> EventLoopFuture 40 | func getUsersCount(on container: Container) -> EventLoopFuture 41 | } 42 | 43 | extension BlogUser: Parameter { 44 | public typealias ResolvedParameter = EventLoopFuture 45 | public static func resolveParameter(_ parameter: String, on container: Container) throws -> BlogUser.ResolvedParameter { 46 | let userRepository = try container.make(BlogUserRepository.self) 47 | guard let userID = Int(parameter) else { 48 | throw SteamPressError(identifier: "Invalid-ID-Type", "Unable to convert \(parameter) to a User ID") 49 | } 50 | return userRepository.getUser(id: userID, on: container).unwrap(or: Abort(.notFound)) 51 | } 52 | } 53 | 54 | extension BlogPost: Parameter { 55 | public typealias ResolvedParameter = EventLoopFuture 56 | public static func resolveParameter(_ parameter: String, on container: Container) throws -> EventLoopFuture { 57 | let postRepository = try container.make(BlogPostRepository.self) 58 | guard let postID = Int(parameter) else { 59 | throw SteamPressError(identifier: "Invalid-ID-Type", "Unable to convert \(parameter) to a Post ID") 60 | } 61 | return postRepository.getPost(id: postID, on: container).unwrap(or: Abort(.notFound)) 62 | } 63 | } 64 | 65 | extension BlogTag: Parameter { 66 | public typealias ResolvedParameter = EventLoopFuture 67 | public static func resolveParameter(_ parameter: String, on container: Container) throws -> EventLoopFuture { 68 | let tagRepository = try container.make(BlogTagRepository.self) 69 | return tagRepository.getTag(parameter, on: container).unwrap(or: Abort(.notFound)) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/SearchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SteamPress 3 | import Vapor 4 | import Foundation 5 | 6 | class SearchTests: XCTestCase { 7 | 8 | // MARK: - Properties 9 | var testWorld: TestWorld! 10 | var firstData: TestData! 11 | 12 | var presenter: CapturingBlogPresenter { 13 | return testWorld.context.blogPresenter 14 | } 15 | 16 | // MARK: - Overrides 17 | 18 | override func setUp() { 19 | testWorld = try! TestWorld.create() 20 | firstData = try! testWorld.createPost(title: "Test Path", slugUrl: "test-path") 21 | } 22 | 23 | override func tearDown() { 24 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 25 | } 26 | 27 | // MARK: - Tests 28 | 29 | func testBlogPassedToSearchPageCorrectly() throws { 30 | let response = try testWorld.getResponse(to: "/search?term=Test") 31 | 32 | XCTAssertEqual(response.http.status, .ok) 33 | XCTAssertEqual(presenter.searchTerm, "Test") 34 | XCTAssertEqual(presenter.searchTotalResults, 1) 35 | XCTAssertEqual(presenter.searchPosts?.first?.title, firstData.post.title) 36 | } 37 | 38 | func testThatSearchTermNilIfEmptySearch() throws { 39 | let response = try testWorld.getResponse(to: "/search?term=") 40 | 41 | XCTAssertEqual(response.http.status, .ok) 42 | XCTAssertEqual(presenter.searchPosts?.count, 0) 43 | XCTAssertNil(presenter.searchTerm) 44 | } 45 | 46 | func testThatSearchTermNilIfNoSearchTerm() throws { 47 | let response = try testWorld.getResponse(to: "/search") 48 | 49 | XCTAssertEqual(response.http.status, .ok) 50 | XCTAssertEqual(presenter.searchPosts?.count, 0) 51 | XCTAssertNil(presenter.searchTerm) 52 | } 53 | 54 | func testCorrectPageInformationForSearch() throws { 55 | _ = try testWorld.getResponse(to: "/search?term=Test") 56 | XCTAssertNil(presenter.searchPageInformation?.disqusName) 57 | XCTAssertNil(presenter.searchPageInformation?.googleAnalyticsIdentifier) 58 | XCTAssertNil(presenter.searchPageInformation?.siteTwitterHandle) 59 | XCTAssertNil(presenter.searchPageInformation?.loggedInUser) 60 | XCTAssertEqual(presenter.searchPageInformation?.currentPageURL.absoluteString, "/search") 61 | XCTAssertEqual(presenter.searchPageInformation?.websiteURL.absoluteString, "/") 62 | } 63 | 64 | func testPageInformationGetsLoggedInUserForSearch() throws { 65 | _ = try testWorld.getResponse(to: "/search?term=Test", loggedInUser: firstData.author) 66 | XCTAssertEqual(presenter.searchPageInformation?.loggedInUser?.username, firstData.author.username) 67 | } 68 | 69 | func testSettingEnvVarsWithPageInformationForSearch() throws { 70 | let googleAnalytics = "ABDJIODJWOIJIWO" 71 | let twitterHandle = "3483209fheihgifffe" 72 | let disqusName = "34829u48932fgvfbrtewerg" 73 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 74 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 75 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 76 | _ = try testWorld.getResponse(to: "/search?term=Test") 77 | XCTAssertEqual(presenter.searchPageInformation?.disqusName, disqusName) 78 | XCTAssertEqual(presenter.searchPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 79 | XCTAssertEqual(presenter.searchPageInformation?.siteTwitterHandle, twitterHandle) 80 | } 81 | 82 | func testPaginationInfoSetCorrectly() throws { 83 | try testWorld.createPosts(count: 15, author: firstData.author) 84 | _ = try testWorld.getResponse(to: "/search?term=Test&page=1") 85 | XCTAssertEqual(presenter.searchPaginationTagInfo?.currentPage, 1) 86 | XCTAssertEqual(presenter.searchPaginationTagInfo?.totalPages, 1) 87 | XCTAssertEqual(presenter.searchPaginationTagInfo?.currentQuery, "term=Test&page=1") 88 | } 89 | 90 | func testTagsForSearchPostsSetCorrectly() throws { 91 | let post2 = try testWorld.createPost(title: "Test Search", author: firstData.author) 92 | let post3 = try testWorld.createPost(title: "Test Tags", author: firstData.author) 93 | let tag1Name = "Testing" 94 | let tag2Name = "Search" 95 | let tag1 = try testWorld.createTag(tag1Name, on: post2.post) 96 | _ = try testWorld.createTag(tag2Name, on: firstData.post) 97 | try testWorld.context.repository.add(tag1, to: firstData.post) 98 | 99 | _ = try testWorld.getResponse(to: "/search?term=Test") 100 | let tagsForPosts = try XCTUnwrap(presenter.searchPageTagsForPost) 101 | XCTAssertNil(tagsForPosts[post3.post.blogID!]) 102 | XCTAssertEqual(tagsForPosts[post2.post.blogID!]?.count, 1) 103 | XCTAssertEqual(tagsForPosts[post2.post.blogID!]?.first?.name, tag1Name) 104 | XCTAssertEqual(tagsForPosts[firstData.post.blogID!]?.count, 2) 105 | XCTAssertEqual(tagsForPosts[firstData.post.blogID!]?.first?.name, tag1Name) 106 | XCTAssertEqual(tagsForPosts[firstData.post.blogID!]?.last?.name, tag2Name) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/Presenters/CapturingAdminPresenter.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | import Vapor 3 | 4 | class CapturingAdminPresenter: BlogAdminPresenter { 5 | 6 | // MARK: - BlogPresenter 7 | private(set) var adminViewErrors: [String]? 8 | private(set) var adminViewPosts: [BlogPost]? 9 | private(set) var adminViewUsers: [BlogUser]? 10 | private(set) var adminViewPageInformation: BlogAdminPageInformation? 11 | func createIndexView(on container: Container, posts: [BlogPost], users: [BlogUser], errors: [String]?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 12 | self.adminViewErrors = errors 13 | self.adminViewPosts = posts 14 | self.adminViewUsers = users 15 | self.adminViewPageInformation = pageInformation 16 | return createFutureView(on: container) 17 | } 18 | 19 | private(set) var createPostErrors: [String]? 20 | private(set) var createPostTitle: String? 21 | private(set) var createPostContents: String? 22 | private(set) var createPostTags: [String]? 23 | private(set) var createPostIsEditing: Bool? 24 | private(set) var createPostPost: BlogPost? 25 | private(set) var createPostDraft: Bool? 26 | private(set) var createPostSlugURL: String? 27 | private(set) var createPostTitleError: Bool? 28 | private(set) var createPostContentsError: Bool? 29 | private(set) var createPostPageInformation: BlogAdminPageInformation? 30 | func createPostView(on container: Container, errors: [String]?, title: String?, contents: String?, slugURL: String?, tags: [String]?, isEditing: Bool, post: BlogPost?, isDraft: Bool?, titleError: Bool, contentsError: Bool, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 31 | self.createPostErrors = errors 32 | self.createPostTitle = title 33 | self.createPostContents = contents 34 | self.createPostSlugURL = slugURL 35 | self.createPostTags = tags 36 | self.createPostIsEditing = isEditing 37 | self.createPostPost = post 38 | self.createPostDraft = isDraft 39 | self.createPostTitleError = titleError 40 | self.createPostContentsError = contentsError 41 | self.createPostPageInformation = pageInformation 42 | return createFutureView(on: container) 43 | } 44 | 45 | private(set) var createUserErrors: [String]? 46 | private(set) var createUserName: String? 47 | private(set) var createUserUsername: String? 48 | private(set) var createUserPasswordError: Bool? 49 | private(set) var createUserConfirmPasswordError: Bool? 50 | private(set) var createUserResetPasswordRequired: Bool? 51 | private(set) var createUserUserID: Int? 52 | private(set) var createUserProfilePicture: String? 53 | private(set) var createUserTwitterHandle: String? 54 | private(set) var createUserBiography: String? 55 | private(set) var createUserTagline: String? 56 | private(set) var createUserEditing: Bool? 57 | private(set) var createUserNameError: Bool? 58 | private(set) var createUserUsernameError: Bool? 59 | func createUserView(on container: Container, editing: Bool, errors: [String]?, name: String?, nameError: Bool, username: String?, usernameErorr: Bool, passwordError: Bool, confirmPasswordError: Bool, resetPasswordOnLogin: Bool, userID: Int?, profilePicture: String?, twitterHandle: String?, biography: String?, tagline: String?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 60 | self.createUserEditing = editing 61 | self.createUserErrors = errors 62 | self.createUserName = name 63 | self.createUserUsername = username 64 | self.createUserPasswordError = passwordError 65 | self.createUserConfirmPasswordError = confirmPasswordError 66 | self.createUserUserID = userID 67 | self.createUserProfilePicture = profilePicture 68 | self.createUserTwitterHandle = twitterHandle 69 | self.createUserBiography = biography 70 | self.createUserTagline = tagline 71 | self.createUserNameError = nameError 72 | self.createUserUsernameError = usernameErorr 73 | self.createUserResetPasswordRequired = resetPasswordOnLogin 74 | return createFutureView(on: container) 75 | } 76 | 77 | private(set) var resetPasswordErrors: [String]? 78 | private(set) var resetPasswordError: Bool? 79 | private(set) var resetPasswordConfirmError: Bool? 80 | private(set) var resetPasswordPageInformation: BlogAdminPageInformation? 81 | func createResetPasswordView(on container: Container, errors: [String]?, passwordError: Bool?, confirmPasswordError: Bool?, pageInformation: BlogAdminPageInformation) -> EventLoopFuture { 82 | self.resetPasswordErrors = errors 83 | self.resetPasswordError = passwordError 84 | self.resetPasswordConfirmError = confirmPasswordError 85 | self.resetPasswordPageInformation = pageInformation 86 | return createFutureView(on: container) 87 | } 88 | 89 | // MARK: - Helpers 90 | 91 | func createFutureView(on container: Container) -> EventLoopFuture { 92 | let data = "some HTML".convertToData() 93 | let view = View(data: data) 94 | return container.future(view) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to a Broken Hands project 2 | 3 | :+1::tada: Thank you for wanting to contribute to this project! :tada::+1: 4 | 5 | We ask that you follow a few guidelines when contributing to one of our projects. 6 | 7 | ## Code of Conduct 8 | 9 | This project and everyone participating in it is governed by the [Broken Hands Code of Conduct](https://github.com/brokenhandsio/SteamPress/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [support@brokenhands.io](mailto:support@brokenhandsio). 10 | 11 | # How Can I Contribute? 12 | 13 | ### Reporting Bugs 14 | 15 | This section guides you through submitting a bug report for a Broken Hands project. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer: :computer:, and find related reports :mag_right:. 16 | 17 | Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). 18 | 19 | > **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. 20 | 21 | #### Before Submitting A Bug Report 22 | 23 | * **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Abrokenhandsio)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. 24 | 25 | #### How Do I Submit A (Good) Bug Report? 26 | 27 | Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue on the repository and provide the following information by filling in the issue form. 28 | 29 | Explain the problem and include additional details to help maintainers reproduce the problem: 30 | 31 | * **Use a clear and descriptive title** for the issue to identify the problem. 32 | * **Describe the exact steps which reproduce the problem** in as many details as possible. This usually means including some code, as well as __full__ error messages if applicable. 33 | * **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 34 | * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. 35 | * **Explain which behavior you expected to see instead and why.** 36 | * **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below. 37 | 38 | ### Suggesting Enhancements 39 | 40 | This section guides you through submitting an enhancement suggestion for a Broken Hands project, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:. 41 | 42 | Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in issue form, including the steps that you imagine you would take if the feature you're requesting existed. 43 | 44 | #### Before Submitting An Enhancement Suggestion 45 | 46 | * **Perform a [cursory search](https://github.com/issues?q=+is%3Aissue+user%3Abrokenhandsio)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 47 | 48 | #### How Do I Submit A (Good) Enhancement Suggestion? 49 | 50 | Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue and provide the following information: 51 | 52 | * **Use a clear and descriptive title** for the issue to identify the suggestion. 53 | * **Provide a step-by-step description of the suggested enhancement** in as many details as possible. 54 | * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 55 | * **Describe the current behavior** and **explain which behavior you expected to see instead** and why. 56 | * **Explain why this enhancement would be useful** to other users and isn't something that can or should be implemented as a separate package. 57 | 58 | ### Pull Requests 59 | 60 | * Do not include issue numbers in the PR title 61 | * End all files with a newline 62 | * All new code should be run through `swiftlint` 63 | * All code must run on both Linux and macOS 64 | * All new code must be covered by tests 65 | * All bug fixes must be accompanied by a test which would fail if the bug fix was not implemented 66 | -------------------------------------------------------------------------------- /Sources/SteamPress/Feed Generators/AtomFeedGenerator.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | struct AtomFeedGenerator { 5 | 6 | // MARK: - Properties 7 | let title: String 8 | let description: String 9 | let copyright: String? 10 | let imageURL: String? 11 | 12 | let xmlDeclaration = "" 13 | let feedStart = "" 14 | let feedEnd = "" 15 | let iso8601Formatter = DateFormatter() 16 | 17 | // MARK: - Initialiser 18 | init(title: String, description: String, copyright: String?, imageURL: String?) { 19 | self.title = title 20 | self.description = description 21 | self.copyright = copyright 22 | self.imageURL = imageURL 23 | 24 | iso8601Formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" 25 | iso8601Formatter.locale = Locale(identifier: "en_US_POSIX") 26 | iso8601Formatter.timeZone = TimeZone(secondsFromGMT: 0) 27 | } 28 | 29 | // MARK: - Route Handler 30 | 31 | func feedHandler(_ request: Request) throws -> EventLoopFuture { 32 | 33 | let blogRepository = try request.make(BlogPostRepository.self) 34 | return blogRepository.getAllPostsSortedByPublishDate(includeDrafts: false, on: request).flatMap { posts in 35 | var feed = self.getFeedStart(for: request) 36 | 37 | if !posts.isEmpty { 38 | let postDate = posts[0].lastEdited ?? posts[0].created 39 | feed += "\(self.iso8601Formatter.string(from: postDate))\n" 40 | } else { 41 | feed += "\(self.iso8601Formatter.string(from: Date()))\n" 42 | } 43 | 44 | if let copyright = self.copyright { 45 | feed += "\(copyright)\n" 46 | } 47 | 48 | if let imageURL = self.imageURL { 49 | feed += "\(imageURL)\n" 50 | } 51 | 52 | var postData: [EventLoopFuture] = [] 53 | for post in posts { 54 | try postData.append(post.getPostAtomFeed(blogPath: self.getRootPath(for: request), dateFormatter: self.iso8601Formatter, for: request)) 55 | } 56 | 57 | return postData.flatten(on: request).map { postsInformation in 58 | for postInformation in postsInformation { 59 | feed += postInformation 60 | } 61 | 62 | feed += self.feedEnd 63 | var httpResponse = HTTPResponse(body: feed) 64 | httpResponse.headers.add(name: .contentType, value: "application/atom+xml") 65 | return httpResponse 66 | } 67 | } 68 | } 69 | 70 | // MARK: - Private functions 71 | 72 | private func getFeedStart(for request: Request) -> String { 73 | let blogLink = getRootPath(for: request) + "/" 74 | let feedLink = blogLink + "atom.xml" 75 | return "\(xmlDeclaration)\n\(feedStart)\n\n\(title)\n\(description)\n\(blogLink)\n\n\nSteamPress\n" 76 | } 77 | 78 | private func getRootPath(for request: Request) -> String { 79 | let hostname = request.http.remotePeer.description 80 | let path = request.http.url.path 81 | return "\(hostname)\(path.replacingOccurrences(of: "/atom.xml", with: ""))" 82 | } 83 | } 84 | 85 | fileprivate extension BlogPost { 86 | func getPostAtomFeed(blogPath: String, dateFormatter: DateFormatter, for request: Request) throws -> EventLoopFuture { 87 | let updatedTime = lastEdited ?? created 88 | let authorRepository = try request.make(BlogUserRepository.self) 89 | return authorRepository.getUser(id: author, on: request).flatMap { user in 90 | guard let user = user else { 91 | throw SteamPressError(identifier: "Invalid-relationship", "Blog user with ID \(self.author) not found") 92 | } 93 | guard let postID = self.blogID else { 94 | throw SteamPressError(identifier: "ID-required", "Blog Post has no ID") 95 | } 96 | var postEntry = "\n\(blogPath)/posts-id/\(postID)/\n\(self.title)\n\(dateFormatter.string(from: updatedTime))\n\(dateFormatter.string(from: self.created))\n\n\(user.name)\n\(blogPath)/authors/\(user.username)/\n\n\(try self.description())\n\n" 97 | 98 | let tagRepository = try request.make(BlogTagRepository.self) 99 | return tagRepository.getTags(for: self, on: request).map { tags in 100 | for tag in tags { 101 | if let percentDecodedTag = tag.name.removingPercentEncoding { 102 | postEntry += "\n" 103 | } 104 | } 105 | 106 | postEntry += "\n" 107 | return postEntry 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/SteamPress/Models/Contexts/ContextViews/ViewBlogPost.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | import SwiftSoup 4 | import SwiftMarkdown 5 | 6 | struct ViewBlogPost: Encodable { 7 | var blogID: Int? 8 | var title: String 9 | var contents: String 10 | var author: Int 11 | var created: Date 12 | var lastEdited: Date? 13 | var slugUrl: String 14 | var published: Bool 15 | var longSnippet: String 16 | var createdDateLong: String 17 | var createdDateNumeric: String 18 | var lastEditedDateNumeric: String? 19 | var lastEditedDateLong: String? 20 | var authorName: String 21 | var authorUsername: String 22 | var postImage: String? 23 | var postImageAlt: String? 24 | var description: String 25 | var tags: [ViewBlogTag] 26 | } 27 | 28 | struct ViewBlogPostWithoutTags: Encodable { 29 | var blogID: Int? 30 | var title: String 31 | var contents: String 32 | var author: Int 33 | var created: Date 34 | var lastEdited: Date? 35 | var slugUrl: String 36 | var published: Bool 37 | var longSnippet: String 38 | var createdDateLong: String 39 | var createdDateNumeric: String 40 | var lastEditedDateNumeric: String? 41 | var lastEditedDateLong: String? 42 | var authorName: String 43 | var authorUsername: String 44 | var postImage: String? 45 | var postImageAlt: String? 46 | var description: String 47 | } 48 | 49 | extension BlogPost { 50 | 51 | func toViewPostWithoutTags(authorName: String, authorUsername: String, longFormatter: LongPostDateFormatter, numericFormatter: NumericPostDateFormatter) throws -> ViewBlogPostWithoutTags { 52 | let lastEditedNumeric: String? 53 | let lastEditedDateLong: String? 54 | if let lastEdited = self.lastEdited { 55 | lastEditedNumeric = numericFormatter.formatter.string(from: lastEdited) 56 | lastEditedDateLong = longFormatter.formatter.string(from: lastEdited) 57 | } else { 58 | lastEditedNumeric = nil 59 | lastEditedDateLong = nil 60 | } 61 | 62 | let postImage: String? 63 | let postImageAlt: String? 64 | 65 | let image = try SwiftSoup.parse(markdownToHTML(self.contents)).select("img").first() 66 | 67 | if let imageFound = image { 68 | postImage = try imageFound.attr("src") 69 | do { 70 | let imageAlt = try imageFound.attr("alt") 71 | if imageAlt != "" { 72 | postImageAlt = imageAlt 73 | } else { 74 | postImageAlt = nil 75 | } 76 | } catch { 77 | postImageAlt = nil 78 | } 79 | } else { 80 | postImage = nil 81 | postImageAlt = nil 82 | } 83 | 84 | return try ViewBlogPostWithoutTags(blogID: self.blogID, title: self.title, contents: self.contents, author: self.author, created: self.created, lastEdited: self.lastEdited, slugUrl: self.slugUrl, published: self.published, longSnippet: self.longSnippet(), createdDateLong: longFormatter.formatter.string(from: created), createdDateNumeric: numericFormatter.formatter.string(from: created), lastEditedDateNumeric: lastEditedNumeric, lastEditedDateLong: lastEditedDateLong, authorName: authorName, authorUsername: authorUsername, postImage: postImage, postImageAlt: postImageAlt, description: self.description()) 85 | } 86 | 87 | func toViewPost(authorName: String, authorUsername: String, longFormatter: LongPostDateFormatter, numericFormatter: NumericPostDateFormatter, tags: [BlogTag]) throws -> ViewBlogPost { 88 | let viewPost = try self.toViewPostWithoutTags(authorName: authorName, authorUsername: authorUsername, longFormatter: longFormatter, numericFormatter: numericFormatter) 89 | 90 | let viewTags = try tags.map { try $0.toViewBlogTag() } 91 | 92 | return ViewBlogPost(blogID: viewPost.blogID, title: viewPost.title, contents: viewPost.contents, author: viewPost.author, created: viewPost.created, lastEdited: viewPost.lastEdited, slugUrl: viewPost.slugUrl, published: viewPost.published, longSnippet: viewPost.longSnippet, createdDateLong: viewPost.createdDateLong, createdDateNumeric: viewPost.createdDateNumeric, lastEditedDateNumeric: viewPost.lastEditedDateNumeric, lastEditedDateLong: viewPost.lastEditedDateLong, authorName: viewPost.authorName, authorUsername: viewPost.authorUsername, postImage: viewPost.postImage, postImageAlt: viewPost.postImageAlt, description: viewPost.description, tags: viewTags) 93 | } 94 | } 95 | 96 | extension Array where Element: BlogPost { 97 | func convertToViewBlogPosts(authors: [BlogUser], tagsForPosts: [Int: [BlogTag]], on container: Container) throws -> [ViewBlogPost] { 98 | let longDateFormatter = try container.make(LongPostDateFormatter.self) 99 | let numericDateFormatter = try container.make(NumericPostDateFormatter.self) 100 | let viewPosts = try self.map { post -> ViewBlogPost in 101 | guard let blogID = post.blogID else { 102 | throw SteamPressError(identifier: "ViewBlogPost", "Post has no ID set") 103 | } 104 | return try post.toViewPost(authorName: authors.getAuthorName(id: post.author), authorUsername: authors.getAuthorUsername(id: post.author), longFormatter: longDateFormatter, numericFormatter: numericDateFormatter, tags: tagsForPosts[blogID] ?? []) 105 | } 106 | return viewPosts 107 | } 108 | 109 | func convertToViewBlogPostsWithoutTags(authors: [BlogUser], on container: Container) throws -> [ViewBlogPostWithoutTags] { 110 | let longDateFormatter = try container.make(LongPostDateFormatter.self) 111 | let numericDateFormatter = try container.make(NumericPostDateFormatter.self) 112 | let viewPosts = try self.map { post -> ViewBlogPostWithoutTags in 113 | return try post.toViewPostWithoutTags(authorName: authors.getAuthorName(id: post.author), authorUsername: authors.getAuthorUsername(id: post.author), longFormatter: longDateFormatter, numericFormatter: numericDateFormatter) 114 | } 115 | return viewPosts 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/Admin/LoginController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | struct LoginController: RouteCollection { 5 | 6 | // MARK: - Properties 7 | private let pathCreator: BlogPathCreator 8 | 9 | // MARK: - Initialiser 10 | init(pathCreator: BlogPathCreator) { 11 | self.pathCreator = pathCreator 12 | } 13 | 14 | // MARK: - Route setup 15 | func boot(router: Router) throws { 16 | router.get("login", use: loginHandler) 17 | router.post("login", use: loginPostHandler) 18 | 19 | let redirectMiddleware = BlogLoginRedirectAuthMiddleware(pathCreator: pathCreator) 20 | let protectedRoutes = router.grouped(redirectMiddleware) 21 | protectedRoutes.post("logout", use: logoutHandler) 22 | protectedRoutes.get("resetPassword", use: resetPasswordHandler) 23 | protectedRoutes.post("resetPassword", use: resetPasswordPostHandler) 24 | } 25 | 26 | // MARK: - Route handlers 27 | func loginHandler(_ req: Request) throws -> EventLoopFuture { 28 | let loginRequied = (try? req.query.get(Bool.self, at: "loginRequired")) != nil 29 | let presenter = try req.make(BlogPresenter.self) 30 | return try presenter.loginView(on: req, loginWarning: loginRequied, errors: nil, username: nil, usernameError: false, passwordError: false, rememberMe: false, pageInformation: req.pageInformation()) 31 | } 32 | 33 | func loginPostHandler(_ req: Request) throws -> EventLoopFuture { 34 | let loginData = try req.content.syncDecode(LoginData.self) 35 | var loginErrors = [String]() 36 | var usernameError = false 37 | var passwordError = false 38 | 39 | if loginData.username == nil { 40 | loginErrors.append("You must supply your username") 41 | usernameError = true 42 | } 43 | 44 | if loginData.password == nil { 45 | loginErrors.append("You must supply your password") 46 | passwordError = true 47 | } 48 | 49 | if !loginErrors.isEmpty { 50 | let presenter = try req.make(BlogPresenter.self) 51 | return try presenter.loginView(on: req, loginWarning: false, errors: loginErrors, username: loginData.username, usernameError: usernameError, passwordError: passwordError, rememberMe: loginData.rememberMe ?? false, pageInformation: req.pageInformation()).encode(for: req) 52 | } 53 | 54 | guard let username = loginData.username, let password = loginData.password else { 55 | throw Abort(.internalServerError) 56 | } 57 | 58 | if let rememberMe = loginData.rememberMe, rememberMe { 59 | try req.session()["SteamPressRememberMe"] = "YES" 60 | } else { 61 | try req.session()["SteamPressRememberMe"] = nil 62 | } 63 | 64 | let userRepository = try req.make(BlogUserRepository.self) 65 | return userRepository.getUser(username: username, on: req).flatMap { user in 66 | let verifier = try req.make(PasswordVerifier.self) 67 | guard let user = user, try verifier.verify(password, created: user.password) else { 68 | let loginError = ["Your username or password is incorrect"] 69 | let presenter = try req.make(BlogPresenter.self) 70 | return try presenter.loginView(on: req, loginWarning: false, errors: loginError, username: loginData.username, usernameError: false, passwordError: false, rememberMe: loginData.rememberMe ?? false, pageInformation: req.pageInformation()).encode(for: req) 71 | } 72 | try user.authenticateSession(on: req) 73 | return req.future(req.redirect(to: self.pathCreator.createPath(for: "admin"))) 74 | } 75 | } 76 | 77 | func logoutHandler(_ request: Request) throws -> Response { 78 | try request.unauthenticateBlogUserSession() 79 | return request.redirect(to: pathCreator.createPath(for: pathCreator.blogPath)) 80 | } 81 | 82 | func resetPasswordHandler(_ req: Request) throws -> EventLoopFuture { 83 | let presenter = try req.make(BlogAdminPresenter.self) 84 | return try presenter.createResetPasswordView(on: req, errors: nil, passwordError: nil, confirmPasswordError: nil, pageInformation: req.adminPageInfomation()) 85 | } 86 | 87 | func resetPasswordPostHandler(_ req: Request) throws -> EventLoopFuture { 88 | let data = try req.content.syncDecode(ResetPasswordData.self) 89 | 90 | var resetPasswordErrors = [String]() 91 | var passwordError: Bool? 92 | var confirmPasswordError: Bool? 93 | 94 | guard let password = data.password, let confirmPassword = data.confirmPassword else { 95 | 96 | if data.password == nil { 97 | resetPasswordErrors.append("You must specify a password") 98 | passwordError = true 99 | } 100 | 101 | if data.confirmPassword == nil { 102 | resetPasswordErrors.append("You must confirm your password") 103 | confirmPasswordError = true 104 | } 105 | 106 | let presenter = try req.make(BlogAdminPresenter.self) 107 | let view = try presenter.createResetPasswordView(on: req, errors: resetPasswordErrors, passwordError: passwordError, confirmPasswordError: confirmPasswordError, pageInformation: req.adminPageInfomation()) 108 | return try view.encode(for: req) 109 | } 110 | 111 | if password != confirmPassword { 112 | resetPasswordErrors.append("Your passwords must match!") 113 | passwordError = true 114 | confirmPasswordError = true 115 | } 116 | 117 | if password.count < 10 { 118 | passwordError = true 119 | resetPasswordErrors.append("Your password must be at least 10 characters long") 120 | } 121 | 122 | guard resetPasswordErrors.isEmpty else { 123 | let presenter = try req.make(BlogAdminPresenter.self) 124 | let view = try presenter.createResetPasswordView(on: req, errors: resetPasswordErrors, passwordError: passwordError, confirmPasswordError: confirmPasswordError, pageInformation: req.adminPageInfomation()) 125 | return try view.encode(for: req) 126 | } 127 | 128 | let user = try req.requireAuthenticated(BlogUser.self) 129 | let hasher = try req.make(PasswordHasher.self) 130 | user.password = try hasher.hash(password) 131 | user.resetPasswordRequired = false 132 | let userRespository = try req.make(BlogUserRepository.self) 133 | let redirect = req.redirect(to: pathCreator.createPath(for: "admin")) 134 | return userRespository.save(user, on: req).transform(to: redirect) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Fakes/Presenters/CapturingBlogPresenter.swift: -------------------------------------------------------------------------------- 1 | import SteamPress 2 | import Vapor 3 | 4 | import Foundation 5 | 6 | class CapturingBlogPresenter: BlogPresenter { 7 | 8 | // MARK: - BlogPresenter 9 | private(set) var indexPosts: [BlogPost]? 10 | private(set) var indexTags: [BlogTag]? 11 | private(set) var indexAuthors: [BlogUser]? 12 | private(set) var indexPageInformation: BlogGlobalPageInformation? 13 | private(set) var indexPaginationTagInfo: PaginationTagInformation? 14 | private(set) var indexTagsForPosts: [Int: [BlogTag]]? 15 | func indexView(on container: Container, posts: [BlogPost], tags: [BlogTag], authors: [BlogUser], tagsForPosts: [Int : [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 16 | self.indexPosts = posts 17 | self.indexTags = tags 18 | self.indexAuthors = authors 19 | self.indexPageInformation = pageInformation 20 | self.indexPaginationTagInfo = paginationTagInfo 21 | self.indexTagsForPosts = tagsForPosts 22 | return TestDataBuilder.createFutureView(on: container) 23 | } 24 | 25 | private(set) var post: BlogPost? 26 | private(set) var postAuthor: BlogUser? 27 | private(set) var postPageInformation: BlogGlobalPageInformation? 28 | private(set) var postPageTags: [BlogTag]? 29 | func postView(on container: Container, post: BlogPost, author: BlogUser, tags: [BlogTag], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 30 | self.post = post 31 | self.postAuthor = author 32 | self.postPageInformation = pageInformation 33 | self.postPageTags = tags 34 | return TestDataBuilder.createFutureView(on: container) 35 | } 36 | 37 | private(set) var allAuthors: [BlogUser]? 38 | private(set) var allAuthorsPostCount: [Int: Int]? 39 | private(set) var allAuthorsPageInformation: BlogGlobalPageInformation? 40 | func allAuthorsView(on container: Container, authors: [BlogUser], authorPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 41 | self.allAuthors = authors 42 | self.allAuthorsPostCount = authorPostCounts 43 | self.allAuthorsPageInformation = pageInformation 44 | return TestDataBuilder.createFutureView(on: container) 45 | } 46 | 47 | private(set) var author: BlogUser? 48 | private(set) var authorPosts: [BlogPost]? 49 | private(set) var authorPostCount: Int? 50 | private(set) var authorPageInformation: BlogGlobalPageInformation? 51 | private(set) var authorPaginationTagInfo: PaginationTagInformation? 52 | private(set) var authorPageTagsForPost: [Int: [BlogTag]]? 53 | func authorView(on container: Container, author: BlogUser, posts: [BlogPost], postCount: Int, tagsForPosts: [Int : [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 54 | self.author = author 55 | self.authorPosts = posts 56 | self.authorPostCount = postCount 57 | self.authorPageInformation = pageInformation 58 | self.authorPaginationTagInfo = paginationTagInfo 59 | self.authorPageTagsForPost = tagsForPosts 60 | return TestDataBuilder.createFutureView(on: container) 61 | } 62 | 63 | private(set) var allTagsPageTags: [BlogTag]? 64 | private(set) var allTagsPagePostCount: [Int: Int]? 65 | private(set) var allTagsPageInformation: BlogGlobalPageInformation? 66 | func allTagsView(on container: Container, tags: [BlogTag], tagPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 67 | self.allTagsPageTags = tags 68 | self.allTagsPagePostCount = tagPostCounts 69 | self.allTagsPageInformation = pageInformation 70 | return TestDataBuilder.createFutureView(on: container) 71 | } 72 | 73 | private(set) var tag: BlogTag? 74 | private(set) var tagPosts: [BlogPost]? 75 | private(set) var tagPageInformation: BlogGlobalPageInformation? 76 | private(set) var tagPaginationTagInfo: PaginationTagInformation? 77 | private(set) var tagPageTotalPosts: Int? 78 | private(set) var tagPageAuthors: [BlogUser]? 79 | func tagView(on container: Container, tag: BlogTag, posts: [BlogPost], authors: [BlogUser], totalPosts: Int, pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 80 | self.tag = tag 81 | self.tagPosts = posts 82 | self.tagPageInformation = pageInformation 83 | self.tagPaginationTagInfo = paginationTagInfo 84 | self.tagPageTotalPosts = totalPosts 85 | self.tagPageAuthors = authors 86 | return TestDataBuilder.createFutureView(on: container) 87 | } 88 | 89 | private(set) var searchPosts: [BlogPost]? 90 | private(set) var searchAuthors: [BlogUser]? 91 | private(set) var searchTerm: String? 92 | private(set) var searchTotalResults: Int? 93 | private(set) var searchPageInformation: BlogGlobalPageInformation? 94 | private(set) var searchPaginationTagInfo: PaginationTagInformation? 95 | private(set) var searchPageTagsForPost: [Int: [BlogTag]]? 96 | func searchView(on container: Container, totalResults: Int, posts: [BlogPost], authors: [BlogUser], searchTerm: String?, tagsForPosts: [Int : [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 97 | self.searchPosts = posts 98 | self.searchTerm = searchTerm 99 | self.searchPageInformation = pageInformation 100 | self.searchTotalResults = totalResults 101 | self.searchAuthors = authors 102 | self.searchPaginationTagInfo = paginationTagInfo 103 | self.searchPageTagsForPost = tagsForPosts 104 | return TestDataBuilder.createFutureView(on: container) 105 | } 106 | 107 | private(set) var loginWarning: Bool? 108 | private(set) var loginErrors: [String]? 109 | private(set) var loginUsername: String? 110 | private(set) var loginUsernameError: Bool? 111 | private(set) var loginPasswordError: Bool? 112 | private(set) var loginPageInformation: BlogGlobalPageInformation? 113 | private(set) var loginPageRememberMe: Bool? 114 | func loginView(on container: Container, loginWarning: Bool, errors: [String]?, username: String?, usernameError: Bool, passwordError: Bool, rememberMe: Bool, pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 115 | self.loginWarning = loginWarning 116 | self.loginErrors = errors 117 | self.loginUsername = username 118 | self.loginUsernameError = usernameError 119 | self.loginPasswordError = passwordError 120 | self.loginPageInformation = pageInformation 121 | self.loginPageRememberMe = rememberMe 122 | return TestDataBuilder.createFutureView(on: container) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/SteamPress/Views/PaginatorTag.swift: -------------------------------------------------------------------------------- 1 | import TemplateKit 2 | 3 | public final class PaginatorTag: TagRenderer { 4 | public enum Error: Swift.Error { 5 | case expectedPaginationInformation 6 | } 7 | 8 | let paginationLabel: String? 9 | 10 | public init(paginationLabel: String? = nil) { 11 | self.paginationLabel = paginationLabel 12 | } 13 | 14 | public static let name = "paginator" 15 | 16 | public func render(tag: TagContext) throws -> EventLoopFuture { 17 | try tag.requireNoBody() 18 | 19 | guard let paginationInformaton = tag.context.data.dictionary?["paginationTagInformation"] else { 20 | throw Error.expectedPaginationInformation 21 | } 22 | 23 | guard let currentPage = paginationInformaton.dictionary?["currentPage"]?.int, 24 | let totalPages = paginationInformaton.dictionary?["totalPages"]?.int else { 25 | throw Error.expectedPaginationInformation 26 | } 27 | 28 | let currentQuery = paginationInformaton.dictionary?["currentQuery"]?.string 29 | 30 | let previousPage: String? 31 | let nextPage: String? 32 | 33 | if currentPage == 1 { 34 | previousPage = nil 35 | } else { 36 | let previousPageNumber = currentPage - 1 37 | previousPage = buildButtonLink(currentQuery: currentQuery, pageNumber: previousPageNumber) 38 | } 39 | 40 | if currentPage == totalPages { 41 | nextPage = nil 42 | } else { 43 | let nextPageNumber = currentPage + 1 44 | nextPage = buildButtonLink(currentQuery: currentQuery, pageNumber: nextPageNumber) 45 | } 46 | 47 | let data = buildNavigation(currentPage: currentPage, totalPages: totalPages, previousPage: previousPage, nextPage: nextPage, currentQuery: currentQuery) 48 | return tag.eventLoop.future(data) 49 | 50 | } 51 | } 52 | 53 | extension PaginatorTag { 54 | 55 | func buildButtonLink(currentQuery: String?, pageNumber: Int) -> String { 56 | var urlComponents = URLComponents() 57 | if currentQuery == nil { 58 | urlComponents.queryItems = [] 59 | } else { 60 | urlComponents.query = currentQuery 61 | } 62 | if (urlComponents.queryItems?.contains { $0.name == "page" }) ?? false { 63 | urlComponents.queryItems?.removeAll { $0.name == "page" } 64 | } 65 | let pageQuery = URLQueryItem(name: "page", value: "\(pageNumber)") 66 | urlComponents.queryItems?.append(pageQuery) 67 | return "?\(urlComponents.query ?? "")" 68 | } 69 | 70 | func buildBackButton(url: String?) -> String { 71 | guard let url = url else { 72 | return buildLink(title: "«", active: false, link: nil, disabled: true) 73 | } 74 | 75 | return buildLink(title: "«", active: false, link: url, disabled: false) 76 | } 77 | 78 | func buildForwardButton(url: String?) -> String { 79 | guard let url = url else { 80 | return buildLink(title: "»", active: false, link: nil, disabled: true) 81 | } 82 | 83 | return buildLink(title: "»", active: false, link: url, disabled: false) 84 | } 85 | 86 | func buildLinks(currentPage: Int, count: Int, currentQuery: String?) -> String { 87 | var links = "" 88 | 89 | if count == 0 { 90 | return links 91 | } 92 | 93 | for i in 1...count { 94 | if i == currentPage { 95 | links += buildLink(title: "\(i)", active: true, link: nil, disabled: false) 96 | } else { 97 | let link = buildButtonLink(currentQuery: currentQuery, pageNumber: i) 98 | links += buildLink(title: "\(i)", active: false, link: link, disabled: false) 99 | } 100 | } 101 | 102 | return links 103 | } 104 | 105 | func buildNavigation(currentPage: Int, totalPages: Int, previousPage: String?, nextPage: String?, currentQuery: String?) -> TemplateData { 106 | 107 | var result = "" 108 | 109 | let navClass = "paginator" 110 | let ulClass = "pagination justify-content-center" 111 | 112 | var header = "" 118 | 119 | result += header 120 | 121 | result += buildBackButton(url: previousPage) 122 | 123 | result += buildLinks(currentPage: currentPage, count: totalPages, currentQuery: currentQuery) 124 | 125 | result += buildForwardButton(url: nextPage) 126 | 127 | result += footer 128 | 129 | return TemplateData.string(result) 130 | } 131 | 132 | func buildLink(title: String, active: Bool, link: String?, disabled: Bool) -> String { 133 | let activeSpan = "(current)" 134 | 135 | let linkClass = "page-link" 136 | let liClass = "page-item" 137 | 138 | var linkString = "«Previous" 165 | } else if title == "»" { 166 | linkString += " rel=\"next\" aria-label=\"Next\">»Next" 167 | } else { 168 | linkString += ">\(title)" 169 | } 170 | 171 | linkString += "" 172 | } else { 173 | linkString += "«Previous" 177 | } else if title == "»" { 178 | linkString += " aria-label=\"Next\" aria-hidden=\"true\">»Next" 179 | } else { 180 | linkString += ">\(title)" 181 | 182 | if active { 183 | linkString += activeSpan 184 | } 185 | } 186 | } 187 | 188 | linkString += "\n" 189 | 190 | return linkString 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/Helpers/TestDataBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import SteamPress 3 | import Vapor 4 | import Authentication 5 | 6 | struct TestDataBuilder { 7 | 8 | static let longContents = "Welcome to SteamPress!\n\nSteamPress started out as an idea - after all, I was porting sites and backends over to Swift and would like to have a blog as well. Being early days for Server-Side Swift, and embracing Vapor, there wasn't anything available to put a blog on my site, so I did what any self-respecting engineer would do - I made one! Besides, what better way to learn a framework than build a blog!\n\nI plan to put some more posts up going into how I actually wrote SteamPress, going into some Vapor basics like Authentication and other popular #help topics on [Slack](qutheory.slack.com) (I probably need to rewrite a lot of it properly first!) either on here or on https://geeks.brokenhands.io, which will be the engineering site for Broken Hands, which is what a lot of future projects I have planned will be under. \n\n![SteamPress Logo](https://user-images.githubusercontent.com/9938337/29742058-ed41dcc0-8a6f-11e7-9cfc-680501cdfb97.png)\n\n This however requires DynamoDB integration with Vapor (which the Swift SDK work has been started [here](https://github.com/brokenhandsio/AWSwift)) as that is what I use for most of my DB usage (it's cheap, I don't have to manage any DB servers etc and I can tear down/scale web servers and the DB will scale in parallel without me having to do anything). But I digress...\n\n# Usage\n\nI designed SteamPress to be as easy to integrate as possible. Full details can be found in the [repo](https://github.com/brokenhandsio/SteamPress/blob/master/README.md) but as an overview:\n\nYou need to add it as a dependency in your `Package.swift`:\n\n```swift\ndependencies: [\n...,\n.Package(url: \"https://github.com/brokenhandsio/SteamPress\", majorVersion: 0, minor: 1)\n]\n```\n\nNext import it at the top of your `main.swift` (or wherever you link it in):\n\n```swift\nimport SteamPress\n```\n\nFinally initialise it:\n\n```swift\nlet steamPress = SteamPress(drop: drop)\n```\n\nThat’s it! You can then blog away to your hearts content. Note that the first time you access the login page it will create a `admin` user for you, and print the credentials out (you need to restart the Heroku app at this point to flush the logs for some reason if you are trying to run on Heroku)\n\nYou also need to link up all the expected templates, so see the [`README`](https://github.com/brokenhandsio/SteamPress/blob/master/README.md) for that, or look at the [Example Site](https://github.com/brokenhandsio/SteamPressExample) - this code that powers this site!\n\n# Features\n\nOne of the reasons for writing this post is to show off some of the features of SteamPress! As you can see, we have blog posts (obviously!), multiple users for the blog and you can tag blog posts with different labels to help categorise posts. Currently (especially in the example site), the list of users, labels etc isn’t particularly functional but it will be expanded over time. We also have pagination for large number of posts.\n\nThere are also some hidden features that prove useful. You can write posts in markdown and then use the [Markdown Provider](https://github.com/vapor-community/markdown-provider) to easily format your posts. Combine it with some syntax highlighting (I use [http://prismjs.com](http://prismjs.com) on this site and you can easily write code and have it highlighted for you, as soon above. Great for technical blogs especially!\n\n# Roadmap\n\nCurrently I have released SteamPress under version 0 as I expect there to be some significant, most probably breaking, changes coming up to add better functionality. Among these include comments (probably just using [Disqus](https://disqus.com)) and making the site a bit easier and nicer to use with some Javascript to do things like form validation and making the labels UI a bit better. Also it would be nice to improve the experience of writing posts (some sort of preview function?), as well as things like AMP and generally tidying the code up! Also, the site desperately needs some UI love!\n\nOther things include:\n\n* Proper testing!\n* Remember Me functionality for logging in\n* Slug URL for posts - helps SEO and makes life a bit easier!\n* Image uploading\n* Blog drafts\n* Sitemap/RSS feed - again for SEO\n* Searching through the blog\n\nIf you have any ideas, find any bugs or any questions, just create an issue in Github for either the [main engine](https://github.com/brokenhandsio/SteamPress/issues) or the [example site](https://github.com/brokenhandsio/SteamPressExample/issues).\n\nHappy blogging!\n\nTim\n" 9 | 10 | static func anyUser(id: Int? = nil, name: String = "Luke", username: String = "luke", password: String = "password") -> BlogUser { 11 | return BlogUser(userID: id, name: name, username: username, password: password, profilePicture: "https://static.brokenhands.io/steampress/images/authors/luke.png", twitterHandle: "luke", biography: "The last Jedi", tagline: "Who is my father") 12 | } 13 | 14 | static func anyPost(author: BlogUser, title: String = "An Exciting Post!", contents: String = "This is a blog post", slugUrl: String = "some-exciting-title", creationDate: Date = Date(), published: Bool = true, lastEditedDate: Date? = nil) throws -> BlogPost { 15 | let blogPost = try BlogPost(title: title, contents: contents, author: author, creationDate: creationDate, slugUrl: slugUrl, published: published) 16 | if let lastEdited = lastEditedDate { 17 | blogPost.lastEdited = lastEdited 18 | } 19 | return blogPost 20 | } 21 | 22 | static func createPost(on repository: InMemoryRepository, tags: [String]? = nil, createdDate: Date? = nil, title: String = "An Exciting Post!", contents: String = "This is a blog post", slugUrl: String = "an-exciting-post", author: BlogUser? = nil, published: Bool = true) throws -> TestData { 23 | let postAuthor: BlogUser 24 | if let author = author { 25 | postAuthor = author 26 | } else { 27 | if let existingUser = repository.users.first { 28 | postAuthor = existingUser 29 | } else { 30 | postAuthor = TestDataBuilder.anyUser() 31 | repository.add(postAuthor) 32 | } 33 | } 34 | 35 | let post: BlogPost 36 | post = try TestDataBuilder.anyPost(author: postAuthor, title: title, contents: contents, slugUrl: slugUrl, creationDate: createdDate ?? Date(), published: published) 37 | 38 | repository.add(post) 39 | 40 | if let tags = tags { 41 | for tag in tags { 42 | _ = try repository.addTag(name: tag, for: post) 43 | } 44 | } 45 | 46 | return TestData(post: post, author: postAuthor) 47 | } 48 | 49 | static func createUser(on repository: InMemoryRepository) -> BlogUser { 50 | let user = TestDataBuilder.anyUser() 51 | repository.add(user) 52 | return user 53 | } 54 | 55 | static func createFutureView(on container: Container) -> EventLoopFuture { 56 | let data = "some HTML".convertToData() 57 | let view = View(data: data) 58 | return container.future(view) 59 | } 60 | } 61 | 62 | struct TestData { 63 | let post: BlogPost 64 | let author: BlogUser 65 | } 66 | 67 | struct EmptyContent: Content {} 68 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/IndexTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | import Foundation 4 | 5 | class IndexTests: XCTestCase { 6 | 7 | // MARK: - Properties 8 | var testWorld: TestWorld! 9 | var firstData: TestData! 10 | let blogIndexPath = "/" 11 | let postsPerPage = 10 12 | 13 | var presenter: CapturingBlogPresenter { 14 | return testWorld.context.blogPresenter 15 | } 16 | 17 | // MARK: - Overrides 18 | 19 | override func setUp() { 20 | testWorld = try! TestWorld.create(postsPerPage: postsPerPage) 21 | firstData = try! testWorld.createPost(title: "Test Path", slugUrl: "test-path") 22 | } 23 | 24 | override func tearDown() { 25 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 26 | } 27 | 28 | // MARK: - Tests 29 | 30 | func testBlogIndexGetsPostsInReverseOrder() throws { 31 | let secondData = try testWorld.createPost(title: "A New Post") 32 | 33 | _ = try testWorld.getResponse(to: blogIndexPath) 34 | 35 | XCTAssertEqual(presenter.indexPosts?.count, 2) 36 | XCTAssertEqual(presenter.indexPosts?[0].title, secondData.post.title) 37 | XCTAssertEqual(presenter.indexPosts?[1].title, firstData.post.title) 38 | 39 | } 40 | 41 | func testBlogIndexGetsAllTags() throws { 42 | let tag = try testWorld.context.repository.addTag(name: "tatooine") 43 | 44 | _ = try testWorld.getResponse(to: blogIndexPath) 45 | 46 | XCTAssertEqual(presenter.indexTags?.count, 1) 47 | XCTAssertEqual(presenter.indexTags?.first?.name, tag.name) 48 | } 49 | 50 | func testBlogIndexGetsAllTagsForPosts() throws { 51 | let tag1 = "Testing" 52 | let tag2 = "Engineering" 53 | _ = try testWorld.createTag(tag1, on: firstData.post) 54 | _ = try testWorld.createTag(tag2, on: firstData.post) 55 | let post2 = try testWorld.createPost(title: "Something else", author: firstData.author).post 56 | _ = try testWorld.getResponse(to: blogIndexPath) 57 | 58 | let tagForPostInformation = try XCTUnwrap(presenter.indexTagsForPosts) 59 | XCTAssertEqual(tagForPostInformation[firstData.post.blogID!]?.count, 2) 60 | XCTAssertEqual(tagForPostInformation[firstData.post.blogID!]?.first?.name, tag1) 61 | XCTAssertEqual(tagForPostInformation[firstData.post.blogID!]?.last?.name, tag2) 62 | XCTAssertNil(tagForPostInformation[post2.blogID!]) 63 | } 64 | 65 | func testBlogIndexGetsAllAuthors() throws { 66 | _ = try testWorld.getResponse(to: blogIndexPath) 67 | 68 | XCTAssertEqual(presenter.indexAuthors?.count, 1) 69 | XCTAssertEqual(presenter.indexAuthors?.first?.name, firstData.author.name) 70 | } 71 | 72 | func testThatAccessingPathsRouteRedirectsToBlogIndex() throws { 73 | let response = try testWorld.getResponse(to: "/posts/") 74 | XCTAssertEqual(response.http.status, .movedPermanently) 75 | XCTAssertEqual(response.http.headers[.location].first, "/") 76 | } 77 | 78 | func testThatAccessingPathsRouteRedirectsToBlogIndexWithCustomPath() throws { 79 | testWorld = try! TestWorld.create(path: "blog") 80 | let response = try testWorld.getResponse(to: "/blog/posts/") 81 | XCTAssertEqual(response.http.status, .movedPermanently) 82 | XCTAssertEqual(response.http.headers[.location].first, "/blog/") 83 | } 84 | 85 | // MARK: - Pagination Tests 86 | func testIndexOnlyGetsTheSpecifiedNumberOfPosts() throws { 87 | try testWorld.createPosts(count: 15, author: firstData.author) 88 | _ = try testWorld.getResponse(to: blogIndexPath) 89 | XCTAssertEqual(presenter.indexPosts?.count, postsPerPage) 90 | } 91 | 92 | func testIndexGetsCorrectPostsForPage() throws { 93 | try testWorld.createPosts(count: 15, author: firstData.author) 94 | _ = try testWorld.getResponse(to: "/?page=2") 95 | XCTAssertEqual(presenter.indexPosts?.count, 6) 96 | } 97 | 98 | // This is a bit of a dummy test since it should be handled by the DB 99 | func testIndexHandlesIncorrectPageCorrectly() throws { 100 | try testWorld.createPosts(count: 15, author: firstData.author) 101 | _ = try testWorld.getResponse(to: "/?page=3") 102 | XCTAssertEqual(presenter.indexPosts?.count, 0) 103 | } 104 | 105 | func testIndexHandlesNegativePageCorrectly() throws { 106 | try testWorld.createPosts(count: 15, author: firstData.author) 107 | _ = try testWorld.getResponse(to: "/?page=-3") 108 | XCTAssertEqual(presenter.indexPosts?.count, postsPerPage) 109 | } 110 | 111 | func testIndexHandlesPageAsStringSafely() throws { 112 | try testWorld.createPosts(count: 15, author: firstData.author) 113 | _ = try testWorld.getResponse(to: "/?page=three") 114 | XCTAssertEqual(presenter.indexPosts?.count, postsPerPage) 115 | } 116 | 117 | func testPaginationInfoSetCorrectly() throws { 118 | try testWorld.createPosts(count: 15, author: firstData.author) 119 | _ = try testWorld.getResponse(to: "/?page=2") 120 | XCTAssertEqual(presenter.indexPaginationTagInfo?.currentPage, 2) 121 | XCTAssertEqual(presenter.indexPaginationTagInfo?.totalPages, 2) 122 | XCTAssertEqual(presenter.indexPaginationTagInfo?.currentQuery, "page=2") 123 | } 124 | 125 | // MARK: - Page Information 126 | 127 | func testIndexGetsCorrectPageInformation() throws { 128 | _ = try testWorld.getResponse(to: blogIndexPath) 129 | XCTAssertNil(presenter.indexPageInformation?.disqusName) 130 | XCTAssertNil(presenter.indexPageInformation?.googleAnalyticsIdentifier) 131 | XCTAssertNil(presenter.indexPageInformation?.siteTwitterHandle) 132 | XCTAssertNil(presenter.indexPageInformation?.loggedInUser) 133 | XCTAssertEqual(presenter.indexPageInformation?.currentPageURL.absoluteString, "/") 134 | XCTAssertEqual(presenter.indexPageInformation?.websiteURL.absoluteString, "/") 135 | } 136 | 137 | func testIndexPageCurrentPageWhenAtSubPath() throws { 138 | testWorld = try TestWorld.create(path: "blog") 139 | _ = try testWorld.getResponse(to: "/blog") 140 | XCTAssertEqual(presenter.indexPageInformation?.currentPageURL.absoluteString, "/blog") 141 | XCTAssertEqual(presenter.indexPageInformation?.websiteURL.absoluteString, "/") 142 | } 143 | 144 | func testIndexPageInformationGetsLoggedInUser() throws { 145 | _ = try testWorld.getResponse(to: blogIndexPath, loggedInUser: firstData.author) 146 | XCTAssertEqual(presenter.indexPageInformation?.loggedInUser?.username, firstData.author.username) 147 | } 148 | 149 | func testSettingEnvVarsWithPageInformation() throws { 150 | let googleAnalytics = "ABDJIODJWOIJIWO" 151 | let twitterHandle = "3483209fheihgifffe" 152 | let disqusName = "34829u48932fgvfbrtewerg" 153 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 154 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 155 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 156 | _ = try testWorld.getResponse(to: blogIndexPath) 157 | XCTAssertEqual(presenter.indexPageInformation?.disqusName, disqusName) 158 | XCTAssertEqual(presenter.indexPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 159 | XCTAssertEqual(presenter.indexPageInformation?.siteTwitterHandle, twitterHandle) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/TagTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | import SteamPress 4 | 5 | class TagTests: XCTestCase { 6 | 7 | // MARK: - Properties 8 | var app: Application! 9 | var testWorld: TestWorld! 10 | let allTagsRequestPath = "/tags" 11 | let tagRequestPath = "/tags/Tatooine" 12 | let tagName = "Tatooine" 13 | var postData: TestData! 14 | var tag: BlogTag! 15 | var presenter: CapturingBlogPresenter { 16 | return testWorld.context.blogPresenter 17 | } 18 | let postsPerPage = 7 19 | 20 | // MARK: - Overrides 21 | 22 | override func setUp() { 23 | testWorld = try! TestWorld.create(postsPerPage: postsPerPage) 24 | postData = try! testWorld.createPost() 25 | tag = try! testWorld.createTag(tagName, on: postData.post) 26 | } 27 | 28 | override func tearDown() { 29 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 30 | } 31 | 32 | // MARK: - Tests 33 | 34 | func testAllTagsPageGetsAllTags() throws { 35 | let secondPost = try! testWorld.createPost() 36 | let thirdPost = try! testWorld.createPost() 37 | let secondTag = try testWorld.createTag("AnotherTag", on: secondPost.post) 38 | try testWorld.context.repository.add(secondTag, to: thirdPost.post) 39 | _ = try testWorld.getResponse(to: allTagsRequestPath) 40 | 41 | XCTAssertEqual(presenter.allTagsPageTags?.count, 2) 42 | XCTAssertEqual(presenter.allTagsPageTags?.first?.name, tag.name) 43 | XCTAssertEqual(presenter.allTagsPagePostCount?[tag.tagID!], 1) 44 | XCTAssertEqual(presenter.allTagsPagePostCount?[secondTag.tagID!], 2) 45 | } 46 | 47 | func testTagPageGetsOnlyPublishedPostsInDescendingOrder() throws { 48 | let secondPostData = try testWorld.createPost(title: "A later post", author: postData.author) 49 | let draftPost = try testWorld.createPost(published: false) 50 | testWorld.context.repository.addTag(tag, to: secondPostData.post) 51 | testWorld.context.repository.addTag(tag, to: draftPost.post) 52 | 53 | _ = try testWorld.getResponse(to: tagRequestPath) 54 | 55 | XCTAssertEqual(presenter.tagPosts?.count, 2) 56 | XCTAssertEqual(presenter.tagPosts?.first?.title, secondPostData.post.title) 57 | } 58 | 59 | func testTagView() throws { 60 | _ = try testWorld.getResponse(to: tagRequestPath) 61 | 62 | XCTAssertEqual(presenter.tagPosts?.count, 1) 63 | XCTAssertEqual(presenter.tagPosts?.first?.title, postData.post.title) 64 | XCTAssertEqual(presenter.tag?.name, tag.name) 65 | } 66 | 67 | func testTagPageGetsCorrectPageInformation() throws { 68 | _ = try testWorld.getResponse(to: tagRequestPath) 69 | XCTAssertNil(presenter.tagPageInformation?.disqusName) 70 | XCTAssertNil(presenter.tagPageInformation?.googleAnalyticsIdentifier) 71 | XCTAssertNil(presenter.tagPageInformation?.siteTwitterHandle) 72 | XCTAssertNil(presenter.tagPageInformation?.loggedInUser) 73 | XCTAssertEqual(presenter.tagPageInformation?.currentPageURL.absoluteString, tagRequestPath) 74 | XCTAssertEqual(presenter.tagPageInformation?.websiteURL.absoluteString, "/") 75 | } 76 | 77 | func testRequestToURLEncodedTag() throws { 78 | _ = try testWorld.createTag("Some tag") 79 | let response = try testWorld.getResponse(to: "/tags/Some%20tag") 80 | XCTAssertEqual(response.http.status, .ok) 81 | } 82 | 83 | func testTagPageInformationGetsLoggedInUser() throws { 84 | _ = try testWorld.getResponse(to: tagRequestPath, loggedInUser: postData.author) 85 | XCTAssertEqual(presenter.tagPageInformation?.loggedInUser?.username, postData.author.username) 86 | } 87 | 88 | func testSettingEnvVarsWithPageInformation() throws { 89 | let googleAnalytics = "ABDJIODJWOIJIWO" 90 | let twitterHandle = "3483209fheihgifffe" 91 | let disqusName = "34829u48932fgvfbrtewerg" 92 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 93 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 94 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 95 | _ = try testWorld.getResponse(to: tagRequestPath) 96 | XCTAssertEqual(presenter.tagPageInformation?.disqusName, disqusName) 97 | XCTAssertEqual(presenter.tagPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 98 | XCTAssertEqual(presenter.tagPageInformation?.siteTwitterHandle, twitterHandle) 99 | } 100 | 101 | func testCorrectPageInformationForAllTags() throws { 102 | _ = try testWorld.getResponse(to: allTagsRequestPath) 103 | XCTAssertNil(presenter.allTagsPageInformation?.disqusName) 104 | XCTAssertNil(presenter.allTagsPageInformation?.googleAnalyticsIdentifier) 105 | XCTAssertNil(presenter.allTagsPageInformation?.siteTwitterHandle) 106 | XCTAssertNil(presenter.allTagsPageInformation?.loggedInUser) 107 | XCTAssertEqual(presenter.allTagsPageInformation?.currentPageURL.absoluteString, allTagsRequestPath) 108 | XCTAssertEqual(presenter.allTagsPageInformation?.websiteURL.absoluteString, "/") 109 | } 110 | 111 | func testPageInformationGetsLoggedInUserForAllTags() throws { 112 | _ = try testWorld.getResponse(to: allTagsRequestPath, loggedInUser: postData.author) 113 | XCTAssertEqual(presenter.allTagsPageInformation?.loggedInUser?.username, postData.author.username) 114 | } 115 | 116 | func testSettingEnvVarsWithPageInformationForAllTags() throws { 117 | let googleAnalytics = "ABDJIODJWOIJIWO" 118 | let twitterHandle = "3483209fheihgifffe" 119 | let disqusName = "34829u48932fgvfbrtewerg" 120 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 121 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 122 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 123 | _ = try testWorld.getResponse(to: allTagsRequestPath) 124 | XCTAssertEqual(presenter.allTagsPageInformation?.disqusName, disqusName) 125 | XCTAssertEqual(presenter.allTagsPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 126 | XCTAssertEqual(presenter.allTagsPageInformation?.siteTwitterHandle, twitterHandle) 127 | } 128 | 129 | // MARK: - Pagination Tests 130 | func testTagViewOnlyGetsTheSpecifiedNumberOfPosts() throws { 131 | try testWorld.createPosts(count: 15, author: postData.author, tag: tag) 132 | _ = try testWorld.getResponse(to: tagRequestPath) 133 | XCTAssertEqual(presenter.tagPosts?.count, postsPerPage) 134 | } 135 | 136 | func testTagViewGetsCorrectPostsForPage() throws { 137 | try testWorld.createPosts(count: 15, author: postData.author, tag: tag) 138 | _ = try testWorld.getResponse(to: "\(tagRequestPath)?page=3") 139 | XCTAssertEqual(presenter.tagPosts?.count, 2) 140 | XCTAssertEqual(presenter.tagPaginationTagInfo?.currentQuery, "page=3") 141 | } 142 | 143 | func testPaginationInfoSetCorrectly() throws { 144 | try testWorld.createPosts(count: 15, author: postData.author, tag: tag) 145 | _ = try testWorld.getResponse(to: tagRequestPath) 146 | XCTAssertEqual(presenter.tagPaginationTagInfo?.currentPage, 1) 147 | XCTAssertEqual(presenter.tagPaginationTagInfo?.totalPages, 3) 148 | XCTAssertNil(presenter.tagPaginationTagInfo?.currentQuery) 149 | } 150 | 151 | func testPageAuthorsSetCorrectly() throws { 152 | _ = try testWorld.getResponse(to: tagRequestPath) 153 | XCTAssertEqual(presenter.tagPageAuthors?.count, 1) 154 | XCTAssertEqual(presenter.tagPageAuthors?.first?.name, postData.author.name) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/SteamPress/Presenters/ViewBlogPresenter.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import SwiftSoup 3 | import SwiftMarkdown 4 | 5 | public struct ViewBlogPresenter: BlogPresenter { 6 | 7 | public func indexView(on container: Container, posts: [BlogPost], tags: [BlogTag], authors: [BlogUser], tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 8 | do { 9 | let viewRenderer = try container.make(ViewRenderer.self) 10 | let viewPosts = try posts.convertToViewBlogPosts(authors: authors, tagsForPosts: tagsForPosts, on: container) 11 | let viewTags = try tags.map { try $0.toViewBlogTag() } 12 | let context = BlogIndexPageContext(posts: viewPosts, tags: viewTags, authors: authors, pageInformation: pageInformation, paginationTagInformation: paginationTagInfo) 13 | return viewRenderer.render("blog/blog", context) 14 | } catch { 15 | return container.future(error: error) 16 | } 17 | } 18 | 19 | public func postView(on container: Container, post: BlogPost, author: BlogUser, tags: [BlogTag], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 20 | do { 21 | let viewRenderer = try container.make(ViewRenderer.self) 22 | 23 | var postImage: String? 24 | var postImageAlt: String? 25 | if let image = try SwiftSoup.parse(markdownToHTML(post.contents)).select("img").first() { 26 | postImage = try image.attr("src") 27 | let imageAlt = try image.attr("alt") 28 | if imageAlt != "" { 29 | postImageAlt = imageAlt 30 | } 31 | } 32 | let shortSnippet = post.shortSnippet() 33 | let longFormatter = try container.make(LongPostDateFormatter.self) 34 | let numericFormatter = try container.make(NumericPostDateFormatter.self) 35 | let viewPost = try post.toViewPost(authorName: author.name, authorUsername: author.username, longFormatter: longFormatter, numericFormatter: numericFormatter, tags: tags) 36 | 37 | let context = BlogPostPageContext(title: post.title, post: viewPost, author: author, pageInformation: pageInformation, postImage: postImage, postImageAlt: postImageAlt, shortSnippet: shortSnippet) 38 | return viewRenderer.render("blog/post", context) 39 | } catch { 40 | return container.future(error: error) 41 | } 42 | 43 | } 44 | 45 | public func allAuthorsView(on container: Container, authors: [BlogUser], authorPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 46 | do { 47 | let viewRenderer = try container.make(ViewRenderer.self) 48 | var viewAuthors = try authors.map { user -> ViewBlogAuthor in 49 | guard let userID = user.userID else { 50 | throw SteamPressError(identifier: "ViewBlogPresenter", "User ID Was Not Set") 51 | } 52 | return ViewBlogAuthor(userID: userID, name: user.name, username: user.username, resetPasswordRequired: user.resetPasswordRequired, profilePicture: user.profilePicture, twitterHandle: user.twitterHandle, biography: user.biography, tagline: user.tagline, postCount: authorPostCounts[userID] ?? 0) 53 | 54 | } 55 | viewAuthors.sort { $0.postCount > $1.postCount } 56 | let context = AllAuthorsPageContext(pageInformation: pageInformation, authors: viewAuthors) 57 | return viewRenderer.render("blog/authors", context) 58 | } catch { 59 | return container.future(error: error) 60 | } 61 | } 62 | 63 | public func authorView(on container: Container, author: BlogUser, posts: [BlogPost], postCount: Int, tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 64 | do { 65 | let viewRenderer = try container.make(ViewRenderer.self) 66 | let myProfile: Bool 67 | if let loggedInUser = pageInformation.loggedInUser { 68 | myProfile = loggedInUser.userID == author.userID 69 | } else { 70 | myProfile = false 71 | } 72 | let viewPosts = try posts.convertToViewBlogPosts(authors: [author], tagsForPosts: tagsForPosts, on: container) 73 | let context = AuthorPageContext(author: author, posts: viewPosts, pageInformation: pageInformation, myProfile: myProfile, postCount: postCount, paginationTagInformation: paginationTagInfo) 74 | return viewRenderer.render("blog/profile", context) 75 | } catch { 76 | return container.future(error: error) 77 | } 78 | } 79 | 80 | public func allTagsView(on container: Container, tags: [BlogTag], tagPostCounts: [Int: Int], pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 81 | do { 82 | let viewRenderer = try container.make(ViewRenderer.self) 83 | var viewTags = try tags.map { tag -> BlogTagWithPostCount in 84 | guard let tagID = tag.tagID else { 85 | throw SteamPressError(identifier: "ViewBlogPresenter", "Tag ID Was Not Set") 86 | } 87 | guard let urlEncodedName = tag.name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { 88 | throw SteamPressError(identifier: "ViewBlogPresenter", "Failed to URL encoded tag name") 89 | } 90 | return BlogTagWithPostCount(tagID: tagID, name: tag.name, postCount: tagPostCounts[tagID] ?? 0, urlEncodedName: urlEncodedName) 91 | } 92 | viewTags.sort { $0.postCount > $1.postCount } 93 | let context = AllTagsPageContext(title: "All Tags", tags: viewTags, pageInformation: pageInformation) 94 | return viewRenderer.render("blog/tags", context) 95 | } catch { 96 | return container.future(error: error) 97 | } 98 | } 99 | 100 | public func tagView(on container: Container, tag: BlogTag, posts: [BlogPost], authors: [BlogUser], totalPosts: Int, pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 101 | do { 102 | let viewRenderer = try container.make(ViewRenderer.self) 103 | let tagsForPosts = try posts.reduce(into: [Int: [BlogTag]]()) { dict, blog in 104 | guard let blogID = blog.blogID else { 105 | throw SteamPressError(identifier: "ViewBlogPresenter", "Blog has no ID set") 106 | } 107 | dict[blogID] = [tag] 108 | } 109 | 110 | let viewPosts = try posts.convertToViewBlogPosts(authors: authors, tagsForPosts: tagsForPosts, on: container) 111 | let context = TagPageContext(tag: tag, pageInformation: pageInformation, posts: viewPosts, postCount: totalPosts, paginationTagInformation: paginationTagInfo) 112 | return viewRenderer.render("blog/tag", context) 113 | } catch { 114 | return container.future(error: error) 115 | } 116 | } 117 | 118 | public func searchView(on container: Container, totalResults: Int, posts: [BlogPost], authors: [BlogUser], searchTerm: String?, tagsForPosts: [Int: [BlogTag]], pageInformation: BlogGlobalPageInformation, paginationTagInfo: PaginationTagInformation) -> EventLoopFuture { 119 | do { 120 | let viewRenderer = try container.make(ViewRenderer.self) 121 | let viewPosts = try posts.convertToViewBlogPosts(authors: authors, tagsForPosts: tagsForPosts, on: container) 122 | let context = SearchPageContext(searchTerm: searchTerm, posts: viewPosts, totalResults: totalResults, pageInformation: pageInformation, paginationTagInformation: paginationTagInfo) 123 | return viewRenderer.render("blog/search", context) 124 | } catch { 125 | return container.future(error: error) 126 | } 127 | } 128 | 129 | public func loginView(on container: Container, loginWarning: Bool, errors: [String]?, username: String?, usernameError: Bool, passwordError: Bool, rememberMe: Bool, pageInformation: BlogGlobalPageInformation) -> EventLoopFuture { 130 | do { 131 | let viewRenderer = try container.make(ViewRenderer.self) 132 | let context = LoginPageContext(errors: errors, loginWarning: loginWarning, username: username, usernameError: usernameError, passwordError: passwordError, rememberMe: rememberMe, pageInformation: pageInformation) 133 | return viewRenderer.render("blog/admin/login", context) 134 | } catch { 135 | return container.future(error: error) 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/BlogTests/AuthorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Vapor 3 | import SteamPress 4 | 5 | class AuthorTests: XCTestCase { 6 | 7 | // MARK: - Properties 8 | private var app: Application! 9 | private var testWorld: TestWorld! 10 | private let allAuthorsRequestPath = "/authors" 11 | private let authorsRequestPath = "/authors/leia" 12 | private var user: BlogUser! 13 | private var postData: TestData! 14 | private var presenter: CapturingBlogPresenter { 15 | return testWorld.context.blogPresenter 16 | } 17 | private var postsPerPage = 7 18 | 19 | // MARK: - Overrides 20 | 21 | override func setUp() { 22 | testWorld = try! TestWorld.create(postsPerPage: postsPerPage) 23 | user = testWorld.createUser(username: "leia") 24 | postData = try! testWorld.createPost(author: user) 25 | } 26 | 27 | override func tearDown() { 28 | XCTAssertNoThrow(try testWorld.tryAsHardAsWeCanToShutdownApplication()) 29 | } 30 | 31 | // MARK: - Tests 32 | 33 | func testAllAuthorsPageGetAllAuthors() throws { 34 | let newAuthor = testWorld.createUser(username: "han") 35 | _ = try testWorld.createPost(author: newAuthor) 36 | _ = try testWorld.createPost(author: newAuthor) 37 | _ = try testWorld.getResponse(to: allAuthorsRequestPath) 38 | 39 | XCTAssertEqual(presenter.allAuthors?.count, 2) 40 | XCTAssertEqual(presenter.allAuthorsPostCount?[newAuthor.userID!], 2) 41 | XCTAssertEqual(presenter.allAuthorsPostCount?[user.userID!], 1) 42 | XCTAssertEqual(presenter.allAuthors?.last?.name, user.name) 43 | } 44 | 45 | func testAuthorPageGetsOnlyPublishedPostsInDescendingOrder() throws { 46 | let secondPostData = try testWorld.createPost(title: "A later post", author: user) 47 | _ = try testWorld.createPost(author: user, published: false) 48 | 49 | _ = try testWorld.getResponse(to: authorsRequestPath) 50 | 51 | XCTAssertEqual(presenter.authorPosts?.count, 2) 52 | XCTAssertEqual(presenter.authorPosts?.first?.title, secondPostData.post.title) 53 | } 54 | 55 | func testDisabledBlogAuthorsPath() throws { 56 | testWorld = try TestWorld.create(enableAuthorPages: false) 57 | _ = testWorld.createUser(username: "leia") 58 | 59 | let authorResponse = try testWorld.getResponse(to: authorsRequestPath) 60 | let allAuthorsResponse = try testWorld.getResponse(to: allAuthorsRequestPath) 61 | 62 | XCTAssertEqual(authorResponse.http.status, .notFound) 63 | XCTAssertEqual(allAuthorsResponse.http.status, .notFound) 64 | } 65 | 66 | func testAuthorView() throws { 67 | _ = try testWorld.getResponse(to: authorsRequestPath) 68 | 69 | XCTAssertEqual(presenter.author?.username, user.username) 70 | XCTAssertEqual(presenter.authorPosts?.count, 1) 71 | XCTAssertEqual(presenter.authorPosts?.first?.title, postData.post.title) 72 | XCTAssertEqual(presenter.authorPosts?.first?.contents, postData.post.contents) 73 | } 74 | 75 | func testAuthorPageGetsCorrectPageInformation() throws { 76 | _ = try testWorld.getResponse(to: authorsRequestPath) 77 | XCTAssertNil(presenter.authorPageInformation?.disqusName) 78 | XCTAssertNil(presenter.authorPageInformation?.googleAnalyticsIdentifier) 79 | XCTAssertNil(presenter.authorPageInformation?.siteTwitterHandle) 80 | XCTAssertNil(presenter.authorPageInformation?.loggedInUser) 81 | XCTAssertEqual(presenter.authorPageInformation?.currentPageURL.absoluteString, authorsRequestPath) 82 | XCTAssertEqual(presenter.authorPageInformation?.websiteURL.absoluteString, "/") 83 | } 84 | 85 | func testAuthorPageInformationGetsLoggedInUser() throws { 86 | let user = testWorld.createUser() 87 | _ = try testWorld.getResponse(to: authorsRequestPath, loggedInUser: user) 88 | XCTAssertEqual(presenter.authorPageInformation?.loggedInUser?.username, user.username) 89 | } 90 | 91 | func testSettingEnvVarsWithPageInformation() throws { 92 | let googleAnalytics = "ABDJIODJWOIJIWO" 93 | let twitterHandle = "3483209fheihgifffe" 94 | let disqusName = "34829u48932fgvfbrtewerg" 95 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 96 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 97 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 98 | _ = try testWorld.getResponse(to: authorsRequestPath) 99 | XCTAssertEqual(presenter.authorPageInformation?.disqusName, disqusName) 100 | XCTAssertEqual(presenter.authorPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 101 | XCTAssertEqual(presenter.authorPageInformation?.siteTwitterHandle, twitterHandle) 102 | } 103 | 104 | func testCorrectPageInformationForAllAuthors() throws { 105 | _ = try testWorld.getResponse(to: allAuthorsRequestPath) 106 | XCTAssertNil(presenter.allAuthorsPageInformation?.disqusName) 107 | XCTAssertNil(presenter.allAuthorsPageInformation?.googleAnalyticsIdentifier) 108 | XCTAssertNil(presenter.allAuthorsPageInformation?.siteTwitterHandle) 109 | XCTAssertNil(presenter.allAuthorsPageInformation?.loggedInUser) 110 | XCTAssertEqual(presenter.allAuthorsPageInformation?.currentPageURL.absoluteString, allAuthorsRequestPath) 111 | XCTAssertEqual(presenter.allAuthorsPageInformation?.websiteURL.absoluteString, "/") 112 | } 113 | 114 | func testPageInformationGetsLoggedInUserForAllAuthors() throws { 115 | let user = testWorld.createUser() 116 | _ = try testWorld.getResponse(to: allAuthorsRequestPath, loggedInUser: user) 117 | XCTAssertEqual(presenter.allAuthorsPageInformation?.loggedInUser?.username, user.username) 118 | } 119 | 120 | func testSettingEnvVarsWithPageInformationForAllAuthors() throws { 121 | let googleAnalytics = "ABDJIODJWOIJIWO" 122 | let twitterHandle = "3483209fheihgifffe" 123 | let disqusName = "34829u48932fgvfbrtewerg" 124 | setenv("BLOG_GOOGLE_ANALYTICS_IDENTIFIER", googleAnalytics, 1) 125 | setenv("BLOG_SITE_TWITTER_HANDLE", twitterHandle, 1) 126 | setenv("BLOG_DISQUS_NAME", disqusName, 1) 127 | _ = try testWorld.getResponse(to: allAuthorsRequestPath) 128 | XCTAssertEqual(presenter.allAuthorsPageInformation?.disqusName, disqusName) 129 | XCTAssertEqual(presenter.allAuthorsPageInformation?.googleAnalyticsIdentifier, googleAnalytics) 130 | XCTAssertEqual(presenter.allAuthorsPageInformation?.siteTwitterHandle, twitterHandle) 131 | } 132 | 133 | 134 | // MARK: - Pagination Tests 135 | func testAuthorViewOnlyGetsTheSpecifiedNumberOfPosts() throws { 136 | try testWorld.createPosts(count: 15, author: user) 137 | _ = try testWorld.getResponse(to: authorsRequestPath) 138 | XCTAssertEqual(presenter.authorPosts?.count, postsPerPage) 139 | XCTAssertEqual(presenter.authorPaginationTagInfo?.currentPage, 1) 140 | XCTAssertEqual(presenter.authorPaginationTagInfo?.totalPages, 3) 141 | XCTAssertNil(presenter.authorPaginationTagInfo?.currentQuery) 142 | } 143 | 144 | func testAuthorViewGetsCorrectPostsForPage() throws { 145 | try testWorld.createPosts(count: 15, author: user) 146 | _ = try testWorld.getResponse(to: "/authors/leia?page=3") 147 | XCTAssertEqual(presenter.authorPosts?.count, 2) 148 | XCTAssertEqual(presenter.authorPaginationTagInfo?.currentQuery, "page=3") 149 | } 150 | 151 | func testAuthorViewGetsAuthorsTotalPostsEvenIfPaginated() throws { 152 | let totalPosts = 15 153 | try testWorld.createPosts(count: totalPosts, author: user) 154 | _ = try testWorld.getResponse(to: authorsRequestPath) 155 | // One post created in setup 156 | XCTAssertEqual(presenter.authorPostCount, totalPosts + 1) 157 | } 158 | 159 | func testTagsForPostsSetCorrectly() throws { 160 | let post2 = try testWorld.createPost(title: "Test Search", author: user) 161 | let post3 = try testWorld.createPost(title: "Test Tags", author: user) 162 | let tag1Name = "Testing" 163 | let tag2Name = "Search" 164 | let tag1 = try testWorld.createTag(tag1Name, on: post2.post) 165 | _ = try testWorld.createTag(tag2Name, on: postData.post) 166 | try testWorld.context.repository.add(tag1, to: postData.post) 167 | 168 | _ = try testWorld.getResponse(to: "/authors/leia") 169 | let tagsForPosts = try XCTUnwrap(presenter.authorPageTagsForPost) 170 | XCTAssertNil(tagsForPosts[post3.post.blogID!]) 171 | XCTAssertEqual(tagsForPosts[post2.post.blogID!]?.count, 1) 172 | XCTAssertEqual(tagsForPosts[post2.post.blogID!]?.first?.name, tag1Name) 173 | XCTAssertEqual(tagsForPosts[postData.post.blogID!]?.count, 2) 174 | XCTAssertEqual(tagsForPosts[postData.post.blogID!]?.first?.name, tag1Name) 175 | XCTAssertEqual(tagsForPosts[postData.post.blogID!]?.last?.name, tag2Name) 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /Tests/SteamPressTests/ViewTests/BlogViewTests.swift: -------------------------------------------------------------------------------- 1 | @testable import SteamPress 2 | import XCTest 3 | import Vapor 4 | 5 | class BlogViewTests: XCTestCase { 6 | 7 | // MARK: - Properties 8 | var basicContainer: BasicContainer! 9 | var presenter: ViewBlogPresenter! 10 | var author: BlogUser! 11 | var post: BlogPost! 12 | var viewRenderer: CapturingViewRenderer! 13 | var pageInformation: BlogGlobalPageInformation! 14 | var websiteURL: URL! 15 | var currentPageURL: URL! 16 | 17 | // MARK: - Overrides 18 | 19 | override func setUp() { 20 | presenter = ViewBlogPresenter() 21 | basicContainer = BasicContainer(config: Config.default(), environment: Environment.testing, services: .init(), on: EmbeddedEventLoop()) 22 | basicContainer.services.register(ViewRenderer.self) { _ in 23 | return self.viewRenderer 24 | } 25 | basicContainer.services.register(LongPostDateFormatter.self) 26 | basicContainer.services.register(NumericPostDateFormatter.self) 27 | viewRenderer = CapturingViewRenderer(worker: basicContainer) 28 | author = TestDataBuilder.anyUser() 29 | author.userID = 1 30 | let createdDate = Date(timeIntervalSince1970: 1584714638) 31 | let lastEditedDate = Date(timeIntervalSince1970: 1584981458) 32 | post = try! TestDataBuilder.anyPost(author: author, contents: TestDataBuilder.longContents, creationDate: createdDate, lastEditedDate: lastEditedDate) 33 | websiteURL = URL(string: "https://www.brokenhands.io")! 34 | currentPageURL = websiteURL.appendingPathComponent("blog").appendingPathComponent("posts").appendingPathComponent("test-post") 35 | pageInformation = BlogGlobalPageInformation(disqusName: "disqusName", siteTwitterHandle: "twitterHandleSomething", googleAnalyticsIdentifier: "GAString....", loggedInUser: author, websiteURL: websiteURL, currentPageURL: currentPageURL, currentPageEncodedURL: currentPageURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!) 36 | } 37 | 38 | override func tearDown() { 39 | try! basicContainer.syncShutdownGracefully() 40 | } 41 | 42 | // MARK: - Tests 43 | 44 | func testDescriptionOnBlogPostPageIsShortSnippetTextCleaned() throws { 45 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [], pageInformation: pageInformation) 46 | 47 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 48 | let expectedDescription = "Welcome to SteamPress!\nSteamPress started out as an idea - after all, I was porting sites and backends over to Swift and would like to have a blog as well. Being early days for Server-Side Swift, and embracing Vapor, there wasn't anything available to put a blog on my site, so I did what any self-respecting engineer would do - I made one! Besides, what better way to learn a framework than build a blog!" 49 | XCTAssertEqual(context.shortSnippet.trimmingCharacters(in: .whitespacesAndNewlines), expectedDescription) 50 | } 51 | 52 | func testBlogPostPageGetsCorrectParameters() throws { 53 | let tag = BlogTag(id: 1, name: "Engineering") 54 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [tag], pageInformation: pageInformation) 55 | 56 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 57 | 58 | XCTAssertEqual(context.title, post.title) 59 | XCTAssertEqual(context.post.blogID, post.blogID) 60 | XCTAssertEqual(context.post.title, post.title) 61 | XCTAssertEqual(context.post.contents, post.contents) 62 | XCTAssertEqual(context.author.name, author.name) 63 | XCTAssertTrue(context.blogPostPage) 64 | XCTAssertEqual(context.pageInformation.disqusName, pageInformation.disqusName) 65 | XCTAssertEqual(context.pageInformation.siteTwitterHandle, pageInformation.siteTwitterHandle) 66 | XCTAssertEqual(context.pageInformation.googleAnalyticsIdentifier, pageInformation.googleAnalyticsIdentifier) 67 | XCTAssertEqual(context.pageInformation.loggedInUser?.username, pageInformation.loggedInUser?.username) 68 | XCTAssertEqual(context.postImage, "https://user-images.githubusercontent.com/9938337/29742058-ed41dcc0-8a6f-11e7-9cfc-680501cdfb97.png") 69 | XCTAssertEqual(context.postImageAlt, "SteamPress Logo") 70 | XCTAssertEqual(context.pageInformation.currentPageURL.absoluteString, "https://www.brokenhands.io/blog/posts/test-post") 71 | XCTAssertEqual(context.pageInformation.websiteURL.absoluteString, "https://www.brokenhands.io") 72 | XCTAssertEqual(context.post.tags.first?.name, tag.name) 73 | XCTAssertEqual(context.post.authorName, author.name) 74 | XCTAssertEqual(context.post.authorUsername, author.username) 75 | 76 | let expectedDescription = "Welcome to SteamPress! SteamPress started out as an idea - after all, I was porting sites and backends over to Swift and would like to have a blog as well. Being early days for Server-Side Swift, and embracing Vapor, there wasn't anything available to put a blog on my site, so I did what any self-respecting engineer would do - I made one! Besides, what better way to learn a framework than build a blog!" 77 | XCTAssertEqual(context.post.description.trimmingCharacters(in: .whitespacesAndNewlines), expectedDescription) 78 | XCTAssertEqual(context.post.postImage, "https://user-images.githubusercontent.com/9938337/29742058-ed41dcc0-8a6f-11e7-9cfc-680501cdfb97.png") 79 | XCTAssertEqual(context.post.postImageAlt, "SteamPress Logo") 80 | let expectedSnippet = "Welcome to SteamPress!\nSteamPress started out as an idea - after all, I was porting sites and backends over to Swift and would like to have a blog as well. Being early days for Server-Side Swift, and embracing Vapor, there wasn\'t anything available to put a blog on my site, so I did what any self-respecting engineer would do - I made one! Besides, what better way to learn a framework than build a blog!\nI plan to put some more posts up going into how I actually wrote SteamPress, going into some Vapor basics like Authentication and other popular #help topics on [Slack](qutheory.slack.com) (I probably need to rewrite a lot of it properly first!) either on here or on https://geeks.brokenhands.io, which will be the engineering site for Broken Hands, which is what a lot of future projects I have planned will be under. \n![SteamPress Logo](https://user-images.githubusercontent.com/9938337/29742058-ed41dcc0-8a6f-11e7-9cfc-680501cdfb97.png)\n" 81 | XCTAssertEqual(context.post.longSnippet, expectedSnippet) 82 | XCTAssertEqual(context.post.createdDateLong, "Friday, Mar 20, 2020") 83 | XCTAssertEqual(context.post.createdDateNumeric, "2020-03-20T14:30:38.000Z") 84 | XCTAssertEqual(context.post.lastEditedDateLong, "Monday, Mar 23, 2020") 85 | XCTAssertEqual(context.post.lastEditedDateNumeric, "2020-03-23T16:37:38.000Z") 86 | 87 | XCTAssertEqual(viewRenderer.templatePath, "blog/post") 88 | } 89 | 90 | func testDisqusNameNotPassedToBlogPostPageIfNotPassedIn() throws { 91 | let pageInformationWithoutDisqus = BlogGlobalPageInformation(disqusName: nil, siteTwitterHandle: "twitter", googleAnalyticsIdentifier: "google", loggedInUser: author, websiteURL: websiteURL, currentPageURL: currentPageURL, currentPageEncodedURL: currentPageURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!) 92 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [], pageInformation: pageInformationWithoutDisqus) 93 | 94 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 95 | XCTAssertNil(context.pageInformation.disqusName) 96 | } 97 | 98 | func testTwitterHandleNotPassedToBlogPostPageIfNotPassedIn() throws { 99 | let pageInformationWithoutTwitterHandle = BlogGlobalPageInformation(disqusName: "disqus", siteTwitterHandle: nil, googleAnalyticsIdentifier: "google", loggedInUser: author, websiteURL: websiteURL, currentPageURL: currentPageURL, currentPageEncodedURL: currentPageURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!) 100 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [], pageInformation: pageInformationWithoutTwitterHandle) 101 | 102 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 103 | XCTAssertNil(context.pageInformation.siteTwitterHandle) 104 | } 105 | 106 | func testGAIdentifierNotPassedToBlogPostPageIfNotPassedIn() throws { 107 | let pageInformationWithoutGAIdentifier = BlogGlobalPageInformation(disqusName: "disqus", siteTwitterHandle: "twitter", googleAnalyticsIdentifier: nil, loggedInUser: author, websiteURL: websiteURL, currentPageURL: currentPageURL, currentPageEncodedURL: currentPageURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!) 108 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [], pageInformation: pageInformationWithoutGAIdentifier) 109 | 110 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 111 | XCTAssertNil(context.pageInformation.googleAnalyticsIdentifier) 112 | } 113 | 114 | func testGettingTagViewWithURLEncodedName() throws { 115 | let tagName = "Some Tag" 116 | let urlEncodedName = try XCTUnwrap(tagName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)) 117 | let tag = BlogTag(id: 1, name: tagName) 118 | 119 | _ = presenter.postView(on: basicContainer, post: post, author: author, tags: [tag], pageInformation: pageInformation) 120 | 121 | let context = try XCTUnwrap(viewRenderer.capturedContext as? BlogPostPageContext) 122 | XCTAssertEqual(context.post.tags.first?.urlEncodedName, urlEncodedName) 123 | XCTAssertEqual(context.post.tags.first?.name, tagName) 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/BlogController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct BlogController: RouteCollection { 4 | 5 | // MARK: - Properties 6 | fileprivate let blogPostsPath = "posts" 7 | fileprivate let tagsPath = "tags" 8 | fileprivate let authorsPath = "authors" 9 | fileprivate let apiPath = "api" 10 | fileprivate let searchPath = "search" 11 | fileprivate let pathCreator: BlogPathCreator 12 | fileprivate let enableAuthorPages: Bool 13 | fileprivate let enableTagsPages: Bool 14 | fileprivate let postsPerPage: Int 15 | 16 | // MARK: - Initialiser 17 | init(pathCreator: BlogPathCreator, enableAuthorPages: Bool, enableTagPages: Bool, postsPerPage: Int) { 18 | self.pathCreator = pathCreator 19 | self.enableAuthorPages = enableAuthorPages 20 | self.enableTagsPages = enableTagPages 21 | self.postsPerPage = postsPerPage 22 | } 23 | 24 | // MARK: - Add routes 25 | func boot(router: Router) throws { 26 | router.get(use: indexHandler) 27 | router.get(blogPostsPath, String.parameter, use: blogPostHandler) 28 | router.get(blogPostsPath, use: blogPostIndexRedirectHandler) 29 | router.get(searchPath, use: searchHandler) 30 | if enableAuthorPages { 31 | router.get(authorsPath, use: allAuthorsViewHandler) 32 | router.get(authorsPath, String.parameter, use: authorViewHandler) 33 | } 34 | if enableTagsPages { 35 | router.get(tagsPath, BlogTag.parameter, use: tagViewHandler) 36 | router.get(tagsPath, use: allTagsViewHandler) 37 | } 38 | } 39 | 40 | // MARK: - Route Handlers 41 | 42 | func indexHandler(_ req: Request) throws -> EventLoopFuture { 43 | let postRepository = try req.make(BlogPostRepository.self) 44 | let tagRepository = try req.make(BlogTagRepository.self) 45 | let userRepository = try req.make(BlogUserRepository.self) 46 | let paginationInformation = req.getPaginationInformation(postsPerPage: postsPerPage) 47 | return flatMap(postRepository.getAllPostsSortedByPublishDate(includeDrafts: false, on: req, count: postsPerPage, offset: paginationInformation.offset), 48 | tagRepository.getAllTags(on: req), 49 | userRepository.getAllUsers(on: req), 50 | postRepository.getAllPostsCount(includeDrafts: false, on: req), 51 | tagRepository.getTagsForAllPosts(on: req)) { posts, tags, users, totalPostCount, tagsForPosts in 52 | let presenter = try req.make(BlogPresenter.self) 53 | return presenter.indexView(on: req, posts: posts, tags: tags, authors: users, tagsForPosts: tagsForPosts, pageInformation: try req.pageInformation(), paginationTagInfo: self.getPaginationInformation(currentPage: paginationInformation.page, totalPosts: totalPostCount, currentQuery: req.http.url.query)) 54 | } 55 | } 56 | 57 | func blogPostIndexRedirectHandler(_ req: Request) throws -> Response { 58 | return req.redirect(to: pathCreator.createPath(for: pathCreator.blogPath), type: .permanent) 59 | } 60 | 61 | func blogPostHandler(_ req: Request) throws -> EventLoopFuture { 62 | let blogSlug = try req.parameters.next(String.self) 63 | let blogRepository = try req.make(BlogPostRepository.self) 64 | return blogRepository.getPost(slug: blogSlug, on: req).unwrap(or: Abort(.notFound)).flatMap { post in 65 | let userRepository = try req.make(BlogUserRepository.self) 66 | let tagsRepository = try req.make(BlogTagRepository.self) 67 | let tagsQuery = tagsRepository.getTags(for: post, on: req) 68 | let userQuery = userRepository.getUser(id: post.author, on: req).unwrap(or: Abort(.internalServerError)) 69 | return flatMap(userQuery, tagsQuery) { user, tags in 70 | let presenter = try req.make(BlogPresenter.self) 71 | return presenter.postView(on: req, post: post, author: user, tags: tags, pageInformation: try req.pageInformation()) 72 | } 73 | } 74 | } 75 | 76 | func tagViewHandler(_ req: Request) throws -> EventLoopFuture { 77 | return try req.parameters.next(BlogTag.self).flatMap { tag in 78 | let postRepository = try req.make(BlogPostRepository.self) 79 | let usersRepository = try req.make(BlogUserRepository.self) 80 | let paginationInformation = req.getPaginationInformation(postsPerPage: self.postsPerPage) 81 | let postsQuery = postRepository.getSortedPublishedPosts(for: tag, on: req, count: self.postsPerPage, offset: paginationInformation.offset) 82 | let postCountQuery = postRepository.getPublishedPostCount(for: tag, on: req) 83 | let usersQuery = usersRepository.getAllUsers(on: req) 84 | return flatMap(postsQuery, postCountQuery, usersQuery) { posts, totalPosts, authors in 85 | let presenter = try req.make(BlogPresenter.self) 86 | let paginationTagInfo = self.getPaginationInformation(currentPage: paginationInformation.page, totalPosts: totalPosts, currentQuery: req.http.url.query) 87 | return presenter.tagView(on: req, tag: tag, posts: posts, authors: authors, totalPosts: totalPosts, pageInformation: try req.pageInformation(), paginationTagInfo: paginationTagInfo) 88 | } 89 | } 90 | } 91 | 92 | func authorViewHandler(_ req: Request) throws -> EventLoopFuture { 93 | let authorUsername = try req.parameters.next(String.self) 94 | let userRepository = try req.make(BlogUserRepository.self) 95 | let paginationInformation = req.getPaginationInformation(postsPerPage: postsPerPage) 96 | return userRepository.getUser(username: authorUsername, on: req).flatMap { user in 97 | guard let author = user else { 98 | throw Abort(.notFound) 99 | } 100 | 101 | let postRepository = try req.make(BlogPostRepository.self) 102 | let tagsRepostiory = try req.make(BlogTagRepository.self) 103 | let authorPostQuery = postRepository.getAllPostsSortedByPublishDate(for: author, includeDrafts: false, on: req, count: self.postsPerPage, offset: paginationInformation.offset) 104 | let tagQuery = tagsRepostiory.getTagsForAllPosts(on: req) 105 | let authorPostCountQuery = postRepository.getPostCount(for: author, on: req) 106 | return flatMap(authorPostQuery, authorPostCountQuery, tagQuery) { posts, postCount, tagsForPosts in 107 | let presenter = try req.make(BlogPresenter.self) 108 | let paginationTagInfo = self.getPaginationInformation(currentPage: paginationInformation.page, totalPosts: postCount, currentQuery: req.http.url.query) 109 | return presenter.authorView(on: req, author: author, posts: posts, postCount: postCount, tagsForPosts: tagsForPosts, pageInformation: try req.pageInformation(), paginationTagInfo: paginationTagInfo) 110 | } 111 | } 112 | } 113 | 114 | func allTagsViewHandler(_ req: Request) throws -> EventLoopFuture { 115 | let tagRepository = try req.make(BlogTagRepository.self) 116 | return tagRepository.getAllTagsWithPostCount(on: req).flatMap { tagswithCount in 117 | let presenter = try req.make(BlogPresenter.self) 118 | let allTags = tagswithCount.map { $0.0 } 119 | let tagCounts = try tagswithCount.reduce(into: [Int: Int]()) { 120 | guard let tagID = $1.0.tagID else { 121 | throw SteamPressError(identifier: "BlogController", "Tag ID not set") 122 | } 123 | return $0[tagID] = $1.1 124 | } 125 | return presenter.allTagsView(on: req, tags: allTags, tagPostCounts: tagCounts, pageInformation: try req.pageInformation()) 126 | } 127 | } 128 | 129 | func allAuthorsViewHandler(_ req: Request) throws -> EventLoopFuture { 130 | let presenter = try req.make(BlogPresenter.self) 131 | let authorRepository = try req.make(BlogUserRepository.self) 132 | return authorRepository.getAllUsersWithPostCount(on: req).flatMap { allUsersWithCount in 133 | let allUsers = allUsersWithCount.map { $0.0 } 134 | let authorCounts = try allUsersWithCount.reduce(into: [Int: Int]()) { 135 | guard let userID = $1.0.userID else { 136 | throw SteamPressError(identifier: "BlogController", "User ID not set") 137 | } 138 | return $0[userID] = $1.1 139 | } 140 | return presenter.allAuthorsView(on: req, authors: allUsers, authorPostCounts: authorCounts, pageInformation: try req.pageInformation()) 141 | } 142 | } 143 | 144 | func searchHandler(_ req: Request) throws -> EventLoopFuture { 145 | let preseneter = try req.make(BlogPresenter.self) 146 | let paginationInformation = req.getPaginationInformation(postsPerPage: postsPerPage) 147 | guard let searchTerm = req.query[String.self, at: "term"], !searchTerm.isEmpty else { 148 | let paginationTagInfo = getPaginationInformation(currentPage: paginationInformation.page, totalPosts: 0, currentQuery: req.http.url.query) 149 | return preseneter.searchView(on: req, totalResults: 0, posts: [], authors: [], searchTerm: nil, tagsForPosts: [:], pageInformation: try req.pageInformation(), paginationTagInfo: paginationTagInfo) 150 | } 151 | 152 | let postRepository = try req.make(BlogPostRepository.self) 153 | let authorRepository = try req.make(BlogUserRepository.self) 154 | let tagRepository = try req.make(BlogTagRepository.self) 155 | let postsCountQuery = postRepository.getPublishedPostCount(for: searchTerm, on: req) 156 | let postsQuery = postRepository.findPublishedPostsOrdered(for: searchTerm, on: req, count: self.postsPerPage, offset: paginationInformation.offset) 157 | let tagsQuery = tagRepository.getTagsForAllPosts(on: req) 158 | let userQuery = authorRepository.getAllUsers(on: req) 159 | return flatMap(postsQuery, postsCountQuery, userQuery, tagsQuery) { posts, totalPosts, users, tagsForPosts in 160 | let paginationTagInfo = self.getPaginationInformation(currentPage: paginationInformation.page, totalPosts: totalPosts, currentQuery: req.http.url.query) 161 | return preseneter.searchView(on: req, totalResults: totalPosts, posts: posts, authors: users, searchTerm: searchTerm, tagsForPosts: tagsForPosts, pageInformation: try req.pageInformation(), paginationTagInfo: paginationTagInfo) 162 | } 163 | } 164 | 165 | func getPaginationInformation(currentPage: Int, totalPosts: Int, currentQuery: String?) -> PaginationTagInformation { 166 | let totalPages = Int(ceil(Double(totalPosts) / Double(postsPerPage))) 167 | return PaginationTagInformation(currentPage: currentPage, totalPages: totalPages, currentQuery: currentQuery) 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/Admin/PostAdminController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct PostAdminController: RouteCollection { 4 | 5 | // MARK: - Properties 6 | private let pathCreator: BlogPathCreator 7 | 8 | // MARK: - Initialiser 9 | init(pathCreator: BlogPathCreator) { 10 | self.pathCreator = pathCreator 11 | } 12 | 13 | // MARK: - Route setup 14 | func boot(router: Router) throws { 15 | router.get("createPost", use: createPostHandler) 16 | router.post("createPost", use: createPostPostHandler) 17 | router.get("posts", BlogPost.parameter, "edit", use: editPostHandler) 18 | router.post("posts", BlogPost.parameter, "edit", use: editPostPostHandler) 19 | router.post("posts", BlogPost.parameter, "delete", use: deletePostHandler) 20 | } 21 | 22 | // MARK: - Route handlers 23 | func createPostHandler(_ req: Request) throws -> EventLoopFuture { 24 | let presenter = try req.make(BlogAdminPresenter.self) 25 | return try presenter.createPostView(on: req, errors: nil, title: nil, contents: nil, slugURL: nil, tags: nil, isEditing: false, post: nil, isDraft: nil, titleError: false, contentsError: false, pageInformation: req.adminPageInfomation()) 26 | } 27 | 28 | func createPostPostHandler(_ req: Request) throws -> EventLoopFuture { 29 | let data = try req.content.syncDecode(CreatePostData.self) 30 | let author = try req.requireAuthenticated(BlogUser.self) 31 | 32 | if data.draft == nil && data.publish == nil { 33 | throw Abort(.badRequest) 34 | } 35 | 36 | if let createPostErrors = validatePostCreation(data) { 37 | let presenter = try req.make(BlogAdminPresenter.self) 38 | let view = try presenter.createPostView(on: req, errors: createPostErrors.errors, title: data.title, contents: data.contents, slugURL: nil, tags: data.tags, isEditing: false, post: nil, isDraft: nil, titleError: createPostErrors.titleError, contentsError: createPostErrors.contentsError, pageInformation: req.adminPageInfomation()) 39 | return try view.encode(for: req) 40 | } 41 | 42 | guard let title = data.title, let contents = data.contents else { 43 | throw Abort(.internalServerError) 44 | } 45 | 46 | return try BlogPost.generateUniqueSlugURL(from: title, on: req).flatMap { uniqueSlug in 47 | let newPost = try BlogPost(title: title, contents: contents, author: author, creationDate: Date(), slugUrl: uniqueSlug, published: data.publish != nil) 48 | 49 | let postRepository = try req.make(BlogPostRepository.self) 50 | return postRepository.save(newPost, on: req).flatMap { post in 51 | let tagsRepository = try req.make(BlogTagRepository.self) 52 | 53 | var existingTagsQuery = [EventLoopFuture]() 54 | for tagName in data.tags { 55 | existingTagsQuery.append(tagsRepository.getTag(tagName, on: req)) 56 | } 57 | 58 | return existingTagsQuery.flatten(on: req).flatMap { existingTagsWithOptionals in 59 | let existingTags = existingTagsWithOptionals.compactMap { $0 } 60 | var tagsSaves = [EventLoopFuture]() 61 | for tagName in data.tags { 62 | if !existingTags.contains(where: { $0.name == tagName }) { 63 | let tag = BlogTag(name: tagName) 64 | tagsSaves.append(tagsRepository.save(tag, on: req)) 65 | } 66 | } 67 | 68 | return tagsSaves.flatten(on: req).flatMap { tags in 69 | var tagLinks = [EventLoopFuture]() 70 | for tag in tags { 71 | tagLinks.append(tagsRepository.add(tag, to: post, on: req)) 72 | } 73 | for tag in existingTags { 74 | tagLinks.append(tagsRepository.add(tag, to: post, on: req)) 75 | } 76 | let redirect = req.redirect(to: self.pathCreator.createPath(for: "posts/\(post.slugUrl)")) 77 | return tagLinks.flatten(on: req).transform(to: redirect) 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | func deletePostHandler(_ req: Request) throws -> EventLoopFuture { 85 | return try req.parameters.next(BlogPost.self).flatMap { post in 86 | let tagsRepository = try req.make(BlogTagRepository.self) 87 | return tagsRepository.deleteTags(for: post, on: req).flatMap { 88 | let redirect = req.redirect(to: self.pathCreator.createPath(for: "admin")) 89 | let postRepository = try req.make(BlogPostRepository.self) 90 | return postRepository.delete(post, on: req).transform(to: redirect) 91 | } 92 | } 93 | } 94 | 95 | func editPostHandler(_ req: Request) throws -> EventLoopFuture { 96 | return try req.parameters.next(BlogPost.self).flatMap { post in 97 | let tagsRepository = try req.make(BlogTagRepository.self) 98 | return tagsRepository.getTags(for: post, on: req).flatMap { tags in 99 | let presenter = try req.make(BlogAdminPresenter.self) 100 | return try presenter.createPostView(on: req, errors: nil, title: post.title, contents: post.contents, slugURL: post.slugUrl, tags: tags.map { $0.name }, isEditing: true, post: post, isDraft: !post.published, titleError: false, contentsError: false, pageInformation: req.adminPageInfomation()) 101 | } 102 | } 103 | } 104 | 105 | func editPostPostHandler(_ req: Request) throws -> EventLoopFuture { 106 | let data = try req.content.syncDecode(CreatePostData.self) 107 | return try req.parameters.next(BlogPost.self).flatMap { post in 108 | if let errors = self.validatePostCreation(data) { 109 | let presenter = try req.make(BlogAdminPresenter.self) 110 | return try presenter.createPostView(on: req, errors: errors.errors, title: data.title, contents: data.contents, slugURL: post.slugUrl, tags: data.tags, isEditing: true, post: post, isDraft: !post.published, titleError: errors.titleError, contentsError: errors.contentsError, pageInformation: req.adminPageInfomation()).encode(for: req) 111 | } 112 | 113 | guard let title = data.title, let contents = data.contents else { 114 | throw Abort(.internalServerError) 115 | } 116 | 117 | post.title = title 118 | post.contents = contents 119 | 120 | let slugURLFuture: EventLoopFuture 121 | if let updateSlugURL = data.updateSlugURL, updateSlugURL { 122 | slugURLFuture = try BlogPost.generateUniqueSlugURL(from: title, on: req) 123 | } else { 124 | slugURLFuture = req.future(post.slugUrl) 125 | } 126 | 127 | return slugURLFuture.flatMap { slugURL in 128 | post.slugUrl = slugURL 129 | if post.published { 130 | post.lastEdited = Date() 131 | } else { 132 | post.created = Date() 133 | if let publish = data.publish, publish { 134 | post.published = true 135 | } 136 | } 137 | 138 | let tagsRepository = try req.make(BlogTagRepository.self) 139 | return flatMap(tagsRepository.getTags(for: post, on: req), tagsRepository.getAllTags(on: req)) { existingTags, allTags in 140 | let tagsToUnlink = existingTags.filter { (anExistingTag) -> Bool in 141 | for tagName in data.tags { 142 | if anExistingTag.name == tagName { 143 | return false 144 | } 145 | } 146 | return true 147 | } 148 | var removeTagLinkResults = [EventLoopFuture]() 149 | for tagToUnlink in tagsToUnlink { 150 | removeTagLinkResults.append(tagsRepository.remove(tagToUnlink, from: post, on: req)) 151 | } 152 | 153 | let newTagsNames = data.tags.filter { (tagName) -> Bool in 154 | !existingTags.contains { (existingTag) -> Bool in 155 | existingTag.name == tagName 156 | } 157 | } 158 | 159 | var tagCreateSaves = [EventLoopFuture]() 160 | for newTagName in newTagsNames { 161 | let foundInAllTags = allTags.filter { $0.name == newTagName }.first 162 | if let existingTag = foundInAllTags { 163 | tagCreateSaves.append(req.future(existingTag)) 164 | } else { 165 | let newTag = BlogTag(name: newTagName) 166 | tagCreateSaves.append(tagsRepository.save(newTag, on: req)) 167 | } 168 | } 169 | 170 | return removeTagLinkResults.flatten(on: req).and(tagCreateSaves.flatten(on: req)).flatMap { (_, newTags) in 171 | var postTagLinkResults = [EventLoopFuture]() 172 | for tag in newTags { 173 | postTagLinkResults.append(tagsRepository.add(tag, to: post, on: req)) 174 | } 175 | return postTagLinkResults.flatten(on: req).flatMap { 176 | let redirect = req.redirect(to: self.pathCreator.createPath(for: "posts/\(post.slugUrl)")) 177 | let postRepository = try req.make(BlogPostRepository.self) 178 | return postRepository.save(post, on: req).transform(to: redirect) 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | 186 | // MARK: - Validators 187 | private func validatePostCreation(_ data: CreatePostData) -> CreatePostErrors? { 188 | var createPostErrors = [String]() 189 | var titleError = false 190 | var contentsError = false 191 | 192 | if data.title.isEmptyOrWhitespace() { 193 | createPostErrors.append("You must specify a blog post title") 194 | titleError = true 195 | } 196 | 197 | if data.contents.isEmptyOrWhitespace() { 198 | createPostErrors.append("You must have some content in your blog post") 199 | contentsError = true 200 | } 201 | 202 | if createPostErrors.count == 0 { 203 | return nil 204 | } 205 | 206 | return CreatePostErrors(errors: createPostErrors, titleError: titleError, contentsError: contentsError) 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /Sources/SteamPress/Controllers/Admin/UserAdminController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | struct UserAdminController: RouteCollection { 5 | 6 | // MARK: - Properties 7 | private let pathCreator: BlogPathCreator 8 | 9 | // MARK: - Initialiser 10 | init(pathCreator: BlogPathCreator) { 11 | self.pathCreator = pathCreator 12 | } 13 | 14 | // MARK: - Route setup 15 | func boot(router: Router) throws { 16 | router.get("createUser", use: createUserHandler) 17 | router.post("createUser", use: createUserPostHandler) 18 | router.get("users", BlogUser.parameter, "edit", use: editUserHandler) 19 | router.post("users", BlogUser.parameter, "edit", use: editUserPostHandler) 20 | router.post("users", BlogUser.parameter, "delete", use: deleteUserPostHandler) 21 | } 22 | 23 | // MARK: - Route handlers 24 | func createUserHandler(_ req: Request) throws -> EventLoopFuture { 25 | let presenter = try req.make(BlogAdminPresenter.self) 26 | return try presenter.createUserView(on: req, editing: false, errors: nil, name: nil, nameError: false, username: nil, usernameErorr: false, passwordError: false, confirmPasswordError: false, resetPasswordOnLogin: false, userID: nil, profilePicture: nil, twitterHandle: nil, biography: nil, tagline: nil, pageInformation: req.adminPageInfomation()) 27 | } 28 | 29 | func createUserPostHandler(_ req: Request) throws -> EventLoopFuture { 30 | let data = try req.content.syncDecode(CreateUserData.self) 31 | 32 | return try validateUserCreation(data, on: req).flatMap { createUserErrors in 33 | if let errors = createUserErrors { 34 | let presenter = try req.make(BlogAdminPresenter.self) 35 | let view = try presenter.createUserView(on: req, editing: false, errors: errors.errors, name: data.name, nameError: errors.nameError, username: data.username, usernameErorr: errors.usernameError, passwordError: errors.passwordError, confirmPasswordError: errors.confirmPasswordError, resetPasswordOnLogin: data.resetPasswordOnLogin ?? false, userID: nil, profilePicture: data.profilePicture, twitterHandle: data.twitterHandle, biography: data.biography, tagline: data.tagline, pageInformation: req.adminPageInfomation()) 36 | return try view.encode(for: req) 37 | } 38 | 39 | guard let name = data.name, let username = data.username, let password = data.password else { 40 | throw Abort(.internalServerError) 41 | } 42 | 43 | let hasher = try req.make(PasswordHasher.self) 44 | let hashedPassword = try hasher.hash(password) 45 | let profilePicture = data.profilePicture.isEmptyOrWhitespace() ? nil : data.profilePicture 46 | let twitterHandle = data.twitterHandle.isEmptyOrWhitespace() ? nil : data.twitterHandle 47 | let biography = data.biography.isEmptyOrWhitespace() ? nil : data.biography 48 | let tagline = data.tagline.isEmptyOrWhitespace() ? nil : data.tagline 49 | let newUser = BlogUser(name: name, username: username.lowercased(), password: hashedPassword, profilePicture: profilePicture, twitterHandle: twitterHandle, biography: biography, tagline: tagline) 50 | if let resetPasswordRequired = data.resetPasswordOnLogin, resetPasswordRequired { 51 | newUser.resetPasswordRequired = true 52 | } 53 | let userRepository = try req.make(BlogUserRepository.self) 54 | return userRepository.save(newUser, on: req).map { _ in 55 | return req.redirect(to: self.pathCreator.createPath(for: "admin")) 56 | } 57 | 58 | } 59 | } 60 | 61 | func editUserHandler(_ req: Request) throws -> EventLoopFuture { 62 | return try req.parameters.next(BlogUser.self).flatMap { user in 63 | let presenter = try req.make(BlogAdminPresenter.self) 64 | return try presenter.createUserView(on: req, editing: true, errors: nil, name: user.name, nameError: false, username: user.username, usernameErorr: false, passwordError: false, confirmPasswordError: false, resetPasswordOnLogin: user.resetPasswordRequired, userID: user.userID, profilePicture: user.profilePicture, twitterHandle: user.twitterHandle, biography: user.biography, tagline: user.tagline, pageInformation: req.adminPageInfomation()) 65 | } 66 | } 67 | 68 | func editUserPostHandler(_ req: Request) throws -> EventLoopFuture { 69 | return try req.parameters.next(BlogUser.self).flatMap { user in 70 | let data = try req.content.syncDecode(CreateUserData.self) 71 | 72 | guard let name = data.name, let username = data.username else { 73 | throw Abort(.internalServerError) 74 | } 75 | 76 | return try self.validateUserCreation(data, editing: true, existingUsername: user.username, on: req).flatMap { errors in 77 | if let editUserErrors = errors { 78 | let presenter = try req.make(BlogAdminPresenter.self) 79 | let view = try presenter.createUserView(on: req, editing: true, errors: editUserErrors.errors, name: data.name, nameError: errors?.nameError ?? false, username: data.username, usernameErorr: errors?.usernameError ?? false, passwordError: editUserErrors.passwordError, confirmPasswordError: editUserErrors.confirmPasswordError, resetPasswordOnLogin: data.resetPasswordOnLogin ?? false, userID: user.userID, profilePicture: data.profilePicture, twitterHandle: data.twitterHandle, biography: data.biography, tagline: data.tagline, pageInformation: req.adminPageInfomation()) 80 | return try view.encode(for: req) 81 | } 82 | 83 | user.name = name 84 | user.username = username.lowercased() 85 | 86 | let profilePicture = data.profilePicture.isEmptyOrWhitespace() ? nil : data.profilePicture 87 | let twitterHandle = data.twitterHandle.isEmptyOrWhitespace() ? nil : data.twitterHandle 88 | let biography = data.biography.isEmptyOrWhitespace() ? nil : data.biography 89 | let tagline = data.tagline.isEmptyOrWhitespace() ? nil : data.tagline 90 | 91 | user.profilePicture = profilePicture 92 | user.twitterHandle = twitterHandle 93 | user.biography = biography 94 | user.tagline = tagline 95 | 96 | if let resetPasswordOnLogin = data.resetPasswordOnLogin, resetPasswordOnLogin { 97 | user.resetPasswordRequired = true 98 | } 99 | 100 | if let password = data.password, password != "" { 101 | let hasher = try req.make(PasswordHasher.self) 102 | user.password = try hasher.hash(password) 103 | } 104 | 105 | let redirect = req.redirect(to: self.pathCreator.createPath(for: "admin")) 106 | let userRepository = try req.make(BlogUserRepository.self) 107 | return userRepository.save(user, on: req).transform(to: redirect) 108 | } 109 | } 110 | } 111 | 112 | func deleteUserPostHandler(_ req: Request) throws -> EventLoopFuture { 113 | let userRepository = try req.make(BlogUserRepository.self) 114 | return try flatMap(req.parameters.next(BlogUser.self), userRepository.getUsersCount(on: req)) { user, userCount in 115 | guard userCount > 1 else { 116 | let postRepository = try req.make(BlogPostRepository.self) 117 | return flatMap(postRepository.getAllPostsSortedByPublishDate(includeDrafts: true, on: req), userRepository.getAllUsers(on: req)) { posts, users in 118 | let presenter = try req.make(BlogAdminPresenter.self) 119 | let view = try presenter.createIndexView(on: req, posts: posts, users: users, errors: ["You cannot delete the last user"], pageInformation: req.adminPageInfomation()) 120 | return try view.encode(for: req) 121 | } 122 | } 123 | 124 | let loggedInUser = try req.requireAuthenticated(BlogUser.self) 125 | guard loggedInUser.userID != user.userID else { 126 | let postRepository = try req.make(BlogPostRepository.self) 127 | return flatMap(postRepository.getAllPostsSortedByPublishDate(includeDrafts: true, on: req), userRepository.getAllUsers(on: req)) { posts, users in 128 | let presenter = try req.make(BlogAdminPresenter.self) 129 | let view = try presenter.createIndexView(on: req, posts: posts, users: users, errors: ["You cannot delete yourself whilst logged in"], pageInformation: req.adminPageInfomation()) 130 | return try view.encode(for: req) 131 | } 132 | } 133 | 134 | let redirect = req.redirect(to: self.pathCreator.createPath(for: "admin")) 135 | return userRepository.delete(user, on: req).transform(to: redirect) 136 | } 137 | } 138 | 139 | // MARK: - Validators 140 | private func validateUserCreation(_ data: CreateUserData, editing: Bool = false, existingUsername: String? = nil, on req: Request) throws -> EventLoopFuture { 141 | var createUserErrors = [String]() 142 | var passwordError = false 143 | var confirmPasswordError = false 144 | var nameErorr = false 145 | var usernameError = false 146 | 147 | if data.name.isEmptyOrWhitespace() { 148 | createUserErrors.append("You must specify a name") 149 | nameErorr = true 150 | } 151 | 152 | if data.username.isEmptyOrWhitespace() { 153 | createUserErrors.append("You must specify a username") 154 | usernameError = true 155 | } 156 | 157 | if !editing || !data.password.isEmptyOrWhitespace() { 158 | if data.password.isEmptyOrWhitespace() { 159 | createUserErrors.append("You must specify a password") 160 | passwordError = true 161 | } 162 | 163 | if data.confirmPassword.isEmptyOrWhitespace() { 164 | createUserErrors.append("You must confirm your password") 165 | confirmPasswordError = true 166 | } 167 | } 168 | 169 | if let password = data.password, password != "" { 170 | if password.count < 10 { 171 | createUserErrors.append("Your password must be at least 10 characters long") 172 | passwordError = true 173 | } 174 | 175 | if data.password != data.confirmPassword { 176 | createUserErrors.append("Your passwords must match") 177 | passwordError = true 178 | confirmPasswordError = true 179 | } 180 | } 181 | 182 | do { 183 | try data.validate() 184 | } catch { 185 | createUserErrors.append("The username provided is not valid") 186 | usernameError = true 187 | } 188 | 189 | var usernameUniqueError: EventLoopFuture 190 | let usersRepository = try req.make(BlogUserRepository.self) 191 | if let username = data.username { 192 | if editing && data.username == existingUsername { 193 | usernameUniqueError = req.future(nil) 194 | } else { 195 | usernameUniqueError = usersRepository.getUser(username: username.lowercased(), on: req).map { user in 196 | if user != nil { 197 | return "Sorry that username has already been taken" 198 | } else { 199 | return nil 200 | } 201 | } 202 | } 203 | } else { 204 | usernameUniqueError = req.future(nil) 205 | } 206 | 207 | return usernameUniqueError.map { usernameErrorOccurred in 208 | if let uniqueError = usernameErrorOccurred { 209 | createUserErrors.append(uniqueError) 210 | usernameError = true 211 | } 212 | if createUserErrors.count == 0 { 213 | return nil 214 | } 215 | 216 | let errors = CreateUserErrors(errors: createUserErrors, passwordError: passwordError, confirmPasswordError: confirmPasswordError, nameError: nameErorr, usernameError: usernameError) 217 | 218 | return errors 219 | 220 | } 221 | } 222 | } 223 | --------------------------------------------------------------------------------