├── src ├── DB │ ├── Helpers.hs │ ├── ContributorCall.hs │ ├── Repository.hs │ ├── User.hs │ └── Organisation.hs ├── DB.hs ├── Web │ ├── Sessions │ │ ├── Types.hs │ │ └── Server.hs │ ├── Helpers.hs │ ├── Middleware.hs │ ├── Router.hs │ ├── Types.hs │ ├── FlashAlerts.hs │ ├── Form.hs │ ├── Form │ │ └── Types.hs │ └── Sessions.hs ├── Handler │ ├── Signup.hs │ ├── Login.hs │ ├── User.hs │ ├── Home.hs │ ├── Login │ │ └── Signin.hs │ └── Account │ │ └── Create.hs ├── Templates │ ├── Types.hs │ ├── Helpers.hs │ ├── Partials │ │ └── FlashAlerts.hs │ ├── Error │ │ └── 500.html │ ├── Account │ │ └── signup.html │ └── Home │ │ └── index.html ├── ImportYesod.hs ├── Handler.hs ├── Model │ └── UserModel.hs ├── Templates.hs ├── Environment.hs ├── Server.hs └── Foundation.hs ├── CHANGELOG.md ├── assets ├── js │ └── index.js ├── images │ └── hackage.png ├── tailwind.config.js ├── webpack │ ├── postcss.config.js │ ├── plugins │ │ └── bundle-hash-plugin.js │ └── webpack.config.js ├── css │ └── style.css ├── package.json └── layout.jinja.template ├── app └── Main.hs ├── resources └── matchmaker-frontpage.png ├── .sosrc ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── stylish-haskell-runner.sh │ ├── hlint-runner.sh │ ├── install-nix.sh │ └── ci.yml ├── migrations ├── 20210423115240_organisations.sql ├── 20210423114034_users.sql ├── 20210423181549_contributor_calls.sql ├── 20210423115352_user_organisation.sql └── 20210423181548_repositories.sql ├── cabal.project ├── test ├── DB │ ├── SpecHelpers.hs │ ├── UserSpec.hs │ └── OrganisationSpec.hs ├── Main.hs ├── Web │ └── AccountCreationSpec.hs └── fixtures.sql ├── .gitignore ├── .editorconfig ├── environment.sh ├── LICENSE ├── shell.nix ├── README.md ├── Makefile ├── matchmaker.cabal ├── .stylish-haskell.yaml └── cabal.project.freeze /src/DB/Helpers.hs: -------------------------------------------------------------------------------- 1 | module DB.Helpers where 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.0.1.0 – 4 | * Release 5 | -------------------------------------------------------------------------------- /assets/js/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import "../css/style.css"; 3 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import Server 4 | 5 | main :: IO () 6 | main = appMain 7 | -------------------------------------------------------------------------------- /assets/images/hackage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haskellfoundation/matchmaker/HEAD/assets/images/hackage.png -------------------------------------------------------------------------------- /resources/matchmaker-frontpage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/haskellfoundation/matchmaker/HEAD/resources/matchmaker-frontpage.png -------------------------------------------------------------------------------- /src/DB.hs: -------------------------------------------------------------------------------- 1 | module DB where 2 | 3 | import Database.PostgreSQL.Transact (DBT) 4 | 5 | class HasDB m where 6 | runDB :: DBT IO a -> m a 7 | -------------------------------------------------------------------------------- /.sosrc: -------------------------------------------------------------------------------- 1 | - pattern: src/(.*)\.hs 2 | commands: 3 | - cabal build -j10 4 | - cabal run matchmaker 5 | 6 | - pattern: test/(.*)\.hs 7 | commands: 8 | - cabal test 9 | -------------------------------------------------------------------------------- /src/Web/Sessions/Types.hs: -------------------------------------------------------------------------------- 1 | module Web.Sessions.Types where 2 | 3 | newtype UserAssigns = UserAssigns {getUserAssigns :: HashMap Text Text} 4 | deriving newtype (Show, Eq) 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Hi! 👋 Thank you for opening a ticket! 2 | 3 | Describe what you were trying to get done. 4 | Tell us what happened, what went wrong, and what you expected to happen. 5 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /migrations/20210423115240_organisations.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS organisations ( 2 | organisation_id uuid PRIMARY KEY, 3 | organisation_name TEXT NOT NULL, 4 | created_at TIMESTAMPTZ NOT NULL, 5 | updated_at TIMESTAMPTZ NOT NULL 6 | ); 7 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: ./ 2 | 3 | with-compiler: ghc-8.10.4 4 | tests: True 5 | 6 | index-state: 2021-09-06T17:46:21Z 7 | 8 | source-repository-package 9 | type: git 10 | location: https://github.com/tchoutri/pg-entity 11 | tag: 9561504be980735b133f14c9672a6f21503e060f 12 | -------------------------------------------------------------------------------- /test/DB/SpecHelpers.hs: -------------------------------------------------------------------------------- 1 | module DB.SpecHelpers where 2 | 3 | import Database.PostgreSQL.Simple (Connection) 4 | import Database.PostgreSQL.Simple.Migration 5 | 6 | migrate :: Connection -> IO () 7 | migrate conn = void $ runMigrations False conn [MigrationInitialization, MigrationDirectory "./migrations"] 8 | -------------------------------------------------------------------------------- /migrations/20210423114034_users.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | user_id uuid PRIMARY KEY, 3 | username TEXT NOT NULL, 4 | email TEXT NOT NULL UNIQUE, 5 | display_name TEXT NOT NULL, 6 | password TEXT NOT NULL, 7 | created_at TIMESTAMPTZ NOT NULL, 8 | updated_at TIMESTAMPTZ NOT NULL 9 | ); 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.stack-work/ 2 | deployments/prod.sh 3 | Session.vim 4 | .envrc 5 | environment.local.sh 6 | .hie 7 | /static/* 8 | assets/node_modules 9 | dist-newstyle 10 | < 11 | & 12 | .hspec-failures 13 | src/Templates/Layout/layout.html 14 | ghcid.text 15 | /**/ghcid.text 16 | client_session_key.aes 17 | _database/ 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [LICENSE] 14 | insert_final_newline = false 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /src/Handler/Signup.hs: -------------------------------------------------------------------------------- 1 | module Handler.Signup where 2 | 3 | import ImportYesod 4 | import Templates (render) 5 | import Templates.Helpers (emptyAssigns, moduleName) 6 | import Templates.Types (TemplateName (TemplateName)) 7 | 8 | getSignupR :: Handler Html 9 | getSignupR = render $$(moduleName) (TemplateName "signup") emptyAssigns 10 | -------------------------------------------------------------------------------- /src/Handler/Login.hs: -------------------------------------------------------------------------------- 1 | module Handler.Login where 2 | 3 | import ImportYesod 4 | import Templates (render) 5 | import Templates.Helpers (emptyAssigns, moduleName) 6 | import Templates.Types (TemplateName (TemplateName)) 7 | 8 | getLoginR :: Handler Html 9 | getLoginR = 10 | render $$(moduleName) (TemplateName "login") emptyAssigns 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/stylish-haskell-runner.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | git add . 6 | 7 | stylish-haskell -c .stylish-haskell.yaml -r src test app -i 8 | 9 | git status 10 | 11 | set +e 12 | git diff --exit-code 13 | diff_code=$? 14 | 15 | if [ $diff_code -ne 0 ] 16 | then 17 | echo "Test formatting failed" 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /assets/webpack/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-import"), 4 | require("postcss-flexbugs-fixes"), 5 | require("postcss-preset-env")({ 6 | autoprefixer: { 7 | flexbox: "no-2009" 8 | }, 9 | stage: 3 10 | }), 11 | require("tailwindcss"), 12 | require("autoprefixer") 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/hlint-runner.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | git add . 6 | 7 | find app src test -name "*.hs" | xargs -P $(nproc) -I {} hlint --refactor-options="-i" --refactor {} 8 | 9 | git status 10 | 11 | set +e 12 | 13 | git diff --exit-code 14 | diff_code=$? 15 | 16 | if [ $diff_code -ne 0 ] 17 | then 18 | echo "Test Hlint failed" 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /src/Templates/Types.hs: -------------------------------------------------------------------------------- 1 | module Templates.Types where 2 | 3 | -- | A wrapper around 'Text' for module names 4 | newtype ModuleName = ModuleName Text 5 | 6 | -- | A wrapper around 'Text' for template names 7 | newtype TemplateName = TemplateName Text 8 | 9 | -- | A wrapper around 'HashMap Text Text' for template assigns 10 | newtype TemplateAssigns = TemplateAssigns { getAssigns :: HashMap Text Text } 11 | -------------------------------------------------------------------------------- /src/Web/Helpers.hs: -------------------------------------------------------------------------------- 1 | module Web.Helpers where 2 | 3 | import Colourista.IO (cyanMessage) 4 | import Data.Time (getCurrentTime) 5 | import GHC.Stack (popCallStack) 6 | 7 | debug :: HasCallStack => (MonadIO m) => Text -> m () 8 | debug msg = do 9 | ts <- liftIO getCurrentTime 10 | liftIO $ cyanMessage $ show ts <> " [Debug] " <> msg 11 | liftIO $ cyanMessage $ toText $ prettyCallStack $ popCallStack callStack 12 | -------------------------------------------------------------------------------- /migrations/20210423181549_contributor_calls.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS contributor_calls ( 2 | contributor_call_id uuid NOT NULL, 3 | repository_id UUID NOT NULL, 4 | title TEXT NOT NULL, 5 | description TEXT NOT NULL, 6 | created_at TIMESTAMPTZ NOT NULL, 7 | updated_at TIMESTAMPTZ NOT NULL, 8 | CONSTRAINT contributor_calls_fk0 FOREIGN KEY ("repository_id") 9 | REFERENCES "repositories"("repository_id") 10 | ); 11 | -------------------------------------------------------------------------------- /test/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import Test.Hspec 4 | 5 | import qualified DB.OrganisationSpec as OrganisationSpec 6 | import qualified DB.UserSpec as UserSpec 7 | import qualified Web.AccountCreationSpec as AccountCreationSpec 8 | -- import qualified RepositorySpec as RepositorySpec 9 | 10 | main :: IO () 11 | main = hspec spec 12 | 13 | spec :: Spec 14 | spec = do 15 | UserSpec.spec 16 | OrganisationSpec.spec 17 | AccountCreationSpec.spec 18 | -------------------------------------------------------------------------------- /assets/css/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @media (max-width:736px) { 6 | div.optional { 7 | display: none; 8 | } 9 | } 10 | 11 | .flash-alert-error { 12 | position: absolute; 13 | right: 2rem; 14 | min-width: 11%; 15 | margin-top: 1rem; 16 | } 17 | 18 | .flash-alert-info { 19 | position: absolute; 20 | right: 2rem; 21 | min-width: 11%; 22 | margin-top: 1rem; 23 | } 24 | -------------------------------------------------------------------------------- /src/Web/Middleware.hs: -------------------------------------------------------------------------------- 1 | module Web.Middleware where 2 | 3 | import Network.HTTP.Types (status200) 4 | import Network.Wai (Middleware, Request (..), pathInfo, responseLBS) 5 | 6 | heartbeat :: Middleware 7 | heartbeat app req sendResponse = app req $ \res -> 8 | if method `elem` ["GET", "HEAD"] && path == ["heartbeat"] 9 | then sendResponse $ responseLBS status200 [("Content-Type", "text/plain")] "OK." 10 | else sendResponse res 11 | where 12 | method = requestMethod req 13 | path = pathInfo req 14 | -------------------------------------------------------------------------------- /src/Web/Router.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -Wno-unused-imports #-} 2 | module Web.Router (router) where 3 | 4 | import Prelude hiding (get) 5 | import Rowdy.Yesod 6 | import Yesod.Core 7 | import Yesod.Routes.TH.Types (ResourceTree) 8 | 9 | 10 | router :: [ResourceTree String] 11 | router = toYesod $ do 12 | get "HomeR" 13 | "login" // do 14 | get "LoginR" 15 | "signin" // post "LoginSigninR" 16 | 17 | "signup" // get "SignupR" 18 | "account" // "create" // post "AccountCreateR" 19 | "user" // get "UserR" 20 | -------------------------------------------------------------------------------- /migrations/20210423115352_user_organisation.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS user_organisation ( 2 | user_organisation_id uuid PRIMARY KEY, 3 | user_id uuid NOT NULL, 4 | organisation_id uuid NOT NULL, 5 | is_admin bool NOT NULL, 6 | CONSTRAINT user_organisation_fk0 FOREIGN KEY ("user_id") 7 | REFERENCES "users"("user_id"), 8 | CONSTRAINT user_organisation_fk1 FOREIGN KEY ("organisation_id") 9 | REFERENCES "organisations"("organisation_id") 10 | ); 11 | 12 | CREATE INDEX user_organisation_admin ON user_organisation (is_admin); 13 | -------------------------------------------------------------------------------- /environment.sh: -------------------------------------------------------------------------------- 1 | export MATCHMAKER_PORT="8008" 2 | export MATCHMAKER_LOG_LEVEL="debug" 3 | 4 | export DB_HOST="localhost" 5 | export DB_PORT="5432" 6 | export DB_USER="postgres" 7 | export DB_PASSWORD="postgres" 8 | export DB_DATABASE="matchmaker_dev" 9 | export DB_POOL_CONNECTIONS="10" 10 | export DB_SUB_POOLS="10" 11 | export DB_TIMEOUT="10" 12 | 13 | export PG_URI="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_DATABASE}" 14 | export PG_CONNSTRING="host=${DB_HOST} dbname=${DB_DATABASE} user=${DB_USER} password=${DB_PASSWORD}" 15 | 16 | export NIXPKGS_ALLOW_BROKEN=1 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 The Haskell Foundation 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /src/Templates/Helpers.hs: -------------------------------------------------------------------------------- 1 | module Templates.Helpers where 2 | 3 | import qualified Data.HashMap.Strict as HM 4 | import qualified Data.Text as T 5 | import Language.Haskell.TH 6 | import Language.Haskell.TH.Syntax 7 | import qualified Relude.Unsafe as U 8 | 9 | import Templates.Types 10 | 11 | -- | Use this function in a View module so that the template name and location 12 | -- can be inferred from the name of the view 13 | moduleName :: Q (TExp ModuleName) 14 | moduleName = do 15 | name <- loc_module <$> qLocation 16 | [|| ModuleName $ U.last $ T.splitOn "." $ toText @String name ||] 17 | 18 | emptyAssigns :: TemplateAssigns 19 | emptyAssigns = TemplateAssigns HM.empty 20 | -------------------------------------------------------------------------------- /src/Web/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE StrictData #-} 2 | module Web.Types 3 | ( DBError(..) 4 | , MatchmakerError(..) 5 | , WebError(..) 6 | ) where 7 | 8 | data MatchmakerError 9 | = DB DBError 10 | | Web WebError 11 | | TextError {-# UNPACK #-}Text 12 | deriving stock (Eq, Generic, Show) 13 | 14 | data WebError 15 | = LoginFailure 16 | deriving stock (Eq, Generic, Show) 17 | 18 | instance Exception WebError 19 | 20 | data DBError 21 | = ConstraintError {-# UNPACK #-}Text 22 | | NotFound 23 | | TooManyResults 24 | | InsertionError 25 | | DeserialisationError {-# UNPACK #-}Text 26 | deriving stock (Eq, Generic, Show) 27 | 28 | instance Exception DBError 29 | -------------------------------------------------------------------------------- /migrations/20210423181548_repositories.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS repositories ( 2 | repository_id uuid PRIMARY KEY, 3 | organisation_id uuid NOT NULL, 4 | repository_name TEXT NOT NULL, 5 | repository_description TEXT NOT NULL, 6 | repository_url TEXT NOT NULL, 7 | repository_homepage TEXT, 8 | created_at TIMESTAMPTZ NOT NULL, 9 | updated_at TIMESTAMPTZ NOT NULL, 10 | CONSTRAINT repositories_fk0 FOREIGN KEY ("organisation_id") 11 | REFERENCES "organisations"("organisation_id") 12 | ); 13 | 14 | CREATE INDEX repository_name_index ON repositories(repository_name); 15 | CREATE UNIQUE INDEX repo_name_org ON repositories (repository_name, organisation_id); 16 | -------------------------------------------------------------------------------- /src/Handler/User.hs: -------------------------------------------------------------------------------- 1 | module Handler.User where 2 | 3 | import ImportYesod 4 | 5 | import Data.HashMap.Strict as HashMap 6 | 7 | import DB.User 8 | import Templates (render) 9 | import Templates.Helpers (moduleName) 10 | import Templates.Types 11 | import Web.Sessions 12 | 13 | getUserR :: Handler Html 14 | getUserR = do 15 | mUserId <- getUserIdFromSession 16 | assigns <- case mUserId of 17 | Nothing -> pure $ TemplateAssigns HashMap.empty 18 | Just uId -> do 19 | (Just User{..}) <- runDB $ getUserById uId 20 | pure $ TemplateAssigns $ HashMap.fromList [("username", username)] 21 | render $$(moduleName) (TemplateName "show") assigns 22 | -------------------------------------------------------------------------------- /src/Web/FlashAlerts.hs: -------------------------------------------------------------------------------- 1 | module Web.FlashAlerts where 2 | 3 | import ImportYesod 4 | import Templates.Partials.FlashAlerts (errorTemplate, infoTemplate) 5 | import Web.Sessions (popAssign, putAssign) 6 | 7 | getFlashes :: Handler Text 8 | getFlashes = do 9 | maybeError <- getError 10 | maybeInfo <- getInfo 11 | traceShowM maybeInfo 12 | let err = maybe "" errorTemplate maybeError 13 | let info = maybe "" infoTemplate maybeInfo 14 | pure $ err <> info 15 | 16 | putInfo :: Text -> Handler () 17 | putInfo msg = putAssign "flash_alert_info" msg 18 | 19 | putError :: Text -> Handler () 20 | putError msg = putAssign "flash_alert_error" msg 21 | 22 | getInfo :: Handler (Maybe Text) 23 | getInfo = popAssign "flash_alert_info" 24 | 25 | getError :: Handler (Maybe Text) 26 | getError = popAssign "flash_alert_error" 27 | -------------------------------------------------------------------------------- /src/Handler/Home.hs: -------------------------------------------------------------------------------- 1 | module Handler.Home where 2 | 3 | import DB.User (User (..), UserId (..), getUserById) 4 | import qualified Data.HashMap.Strict as HM 5 | import Data.UUID (fromText) 6 | import ImportYesod 7 | import Templates (render) 8 | import Templates.Helpers (moduleName) 9 | import Templates.Types (TemplateAssigns (TemplateAssigns), 10 | TemplateName (TemplateName)) 11 | import Web.Sessions (readAssign) 12 | 13 | getHomeR :: Handler Html 14 | getHomeR = do 15 | mUserId <- readAssign "user_id" $ fmap UserId . fromText 16 | assigns <- 17 | maybe 18 | (pure $ TemplateAssigns HM.empty) 19 | (\uId -> do 20 | (Just user) <- runDB $ getUserById uId 21 | pure $ TemplateAssigns $ HM.fromList [("displayName", displayName user)] 22 | ) 23 | mUserId 24 | render $$(moduleName) (TemplateName "index") assigns 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/ImportYesod.hs: -------------------------------------------------------------------------------- 1 | -- | This module is a convenience module, it re-exports the most common modules 2 | -- required to write a handler: 3 | -- - 'DB' exports the @HasDB@ class, which allows you to use 'runDB' 4 | -- - 'Foundation' exports the actual @Handler@ type as well as the @Foundation@ type 5 | -- - 'Yesod.Core' provides the majority of the yesod functionality you might require 6 | -- i.e. the @Html@ ContentType and 'redirect' 7 | -- 8 | -- A word of caution: It behooves us to keep the re-exports here to a minimum. Choke points 9 | -- like this in the module graph can really explode compilation times, and the more modules 10 | -- that get added here, the harder it becomes to manage module cycles. 11 | module ImportYesod 12 | ( 13 | module DB, 14 | module Foundation, 15 | module Yesod.Core, 16 | ) where 17 | 18 | import DB 19 | import Foundation 20 | import Yesod.Core 21 | -------------------------------------------------------------------------------- /assets/webpack/plugins/bundle-hash-plugin.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const _ = require("lodash"); 3 | const fs = require('fs'); 4 | 5 | const pluginName = "BundleHashPlugin"; 6 | 7 | class BundleHashPlugin { 8 | constructor(options) { 9 | this.options = options; 10 | } 11 | 12 | apply(compiler) { 13 | compiler.hooks.compilation.tap(pluginName, (compilation, compilationParams) => { 14 | console.log(JSON.stringify({compilation})); 15 | // const assets = compilation.getAssets(); 16 | // console.log({assets}) 17 | // const bundle = _.head(_.filter(assets, (o) => _.endsWith(o.name, ".js"))) 18 | console.log(`Writing the bundle name to ${this.options.publicPath}/bundleName.txt`); 19 | fs.writeFileSync(`${this.options.publicPath}/bundleName.txt`, bundle.name, "utf8"); 20 | }); 21 | } 22 | } 23 | 24 | module.exports = { BundleHashPlugin }; 25 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let pkgs = import (builtins.fetchTarball { 2 | # master on 2021-08-01 3 | url = "https://github.com/NixOS/nixpkgs/archive/9fc2cddf24ad1819f17174cbae47789294ea6dc4.tar.gz"; 4 | sha256 = "058l6ry119mkg7pwmm7z4rl1721w0zigklskq48xb5lmgig4l332"; 5 | }) { }; 6 | in with pkgs; 7 | mkShell { 8 | shellHook = '' 9 | source environment.sh 10 | ''; 11 | buildInputs = [ 12 | # Haskell Deps 13 | haskell.compiler.ghc8104 14 | cabal-install 15 | ghcid 16 | hlint 17 | haskellPackages.apply-refact 18 | stylish-haskell 19 | git 20 | 21 | # Frontend Deps 22 | yarn 23 | nodejs-14_x 24 | 25 | # DB Deps 26 | postgresql_12 27 | gmp 28 | zlib 29 | glibcLocales 30 | haskellPackages.postgresql-simple-migration 31 | 32 | # Extra 33 | direnv 34 | ]; 35 | } 36 | -------------------------------------------------------------------------------- /test/Web/AccountCreationSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | 3 | module Web.AccountCreationSpec where 4 | 5 | import Test.Hspec (Spec) 6 | import Test.Hspec.DB (describeDB, itDB) 7 | import Test.Hspec.Expectations.Lifted (expectationFailure) 8 | 9 | import DB.Organisation (getOrganisationByName) 10 | import DB.SpecHelpers (migrate) 11 | import Handler.Account.Create (postAccountCreate) 12 | 13 | postData1 :: [(Text, Text)] 14 | postData1 = 15 | [ ("username" , "wildcat") 16 | , ("email" , "force_captain@horde.io") 17 | , ("displayname", "Catra") 18 | , ("password" , "adorauwu") 19 | ] 20 | 21 | spec :: Spec 22 | spec = describeDB migrate "org" $ do 23 | itDB "Users have a default organisation" $ do 24 | let orgName = "default_org_for_wildcat" 25 | runExceptT (postAccountCreate postData1) >>= \case 26 | Left errors -> expectationFailure $ "Validation error(s): " <> show errors 27 | Right _ -> pure () 28 | whenNothingM_ (getOrganisationByName orgName) 29 | $ expectationFailure "no default org created or name formatting changed" 30 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "scripts": { 5 | "watch-css": "yarn postcss -w css/style.css -o ../static/style.css", 6 | "build": "webpack --config webpack/webpack.config.js", 7 | "watch": "webpack -w --config webpack/webpack.config.js" 8 | }, 9 | "dependencies": { 10 | "dayjs": "^1.10.4", 11 | "postcss-flexbugs-fixes": "^5.0.2", 12 | "postcss-import": "^14.0.0", 13 | "postcss-preset-env": "^6.7.0", 14 | "tailwindcss": "^2.0.2" 15 | }, 16 | "devDependencies": { 17 | "autoprefixer": "^10.2.1", 18 | "copy-webpack-plugin": "^8.1.1", 19 | "css-loader": "^5.1.1", 20 | "html-webpack-plugin": "^5.3.1", 21 | "loader-utils": "^2.0.0", 22 | "lodash": "^4.17.21", 23 | "mini-css-extract-plugin": "^1.3.9", 24 | "postcss": "^8.2.4", 25 | "postcss-cli": "^8.3.1", 26 | "postcss-loader": "^5.1.0", 27 | "postcss-modules": "^4.0.0", 28 | "style-loader": "^2.0.0", 29 | "webpack": "^5.24.4", 30 | "webpack-cli": "^4.5.0", 31 | "webpack-manifest-plugin": "^3.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Web/Form.hs: -------------------------------------------------------------------------------- 1 | module Web.Form ( 2 | module Web.Form, 3 | module Web.Form.Types, 4 | ) where 5 | 6 | import Data.Map hiding (fold, toList) 7 | import ImportYesod 8 | import Templates.Partials.FlashAlerts 9 | import Web.FlashAlerts 10 | import Web.Form.Types 11 | 12 | fieldError :: e -> FormValidation e a 13 | fieldError err = FieldErrors $ err :| [] 14 | 15 | lookupFormFieldTextError :: Text -> Map Text Text -> FormValidation Text Text 16 | lookupFormFieldTextError k m = lookupFormField id k m 17 | 18 | lookupFormField :: (Text -> e) -> Text -> Map Text Text -> FormValidation e Text 19 | lookupFormField err k m = 20 | case lookup k m of 21 | Nothing -> fieldError . err $ k 22 | Just v -> Result v 23 | 24 | lookupOptionalFormField :: Text -> Map Text Text -> FormValidation Text (Maybe Text) 25 | lookupOptionalFormField k m = Result $ lookup k m 26 | 27 | handleMissingFields :: Route Foundation -> FormValidation Text a -> Handler a 28 | handleMissingFields route (FieldErrors fields) = do 29 | putError . errorTemplate . fold . intersperse ", " $ toList fields 30 | redirect route 31 | handleMissingFields _ (Result res) = pure res 32 | -------------------------------------------------------------------------------- /src/Templates/Partials/FlashAlerts.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE QuasiQuotes #-} 2 | module Templates.Partials.FlashAlerts where 3 | 4 | import Data.String.Interpolate 5 | 6 | infoTemplate :: Text -> Text 7 | infoTemplate msg = [i| 8 |
Oops! The page you are looking for does not exist. It might have been moved or deleted.
10 | 13 | 16 |
4 |
5 | ## Description
6 |
7 | *Matchmaker* is a project of the Haskell Foundation to help open-source maintainers and contributors find each-other,
8 | and provide a smoother experience for people wishing to invest themselves in the opens-source Haskell ecosystem.
9 |
10 | ## Prerequisites
11 |
12 | * PostgreSQL 12 or higher
13 | * GHC 8.10.4
14 | * Yarn 1.22 or higher
15 |
16 | *Note*
17 | There is a `shell.nix` file provided for convenience. However it is far from perfect.
18 | It will not manage a local installation of PostgreSQL for you.
19 | You should be able to work in a pure shell. If not, file a bug!
20 | The nix shell should source all environment variables when you enter it.
21 |
22 | The `Makefile` contains all the development-related scripts you'll need. Please
23 | refer to the output of `make help` for more information.
24 |
25 | ## Running Matchmaker
26 |
27 | ### Backend
28 |
29 | ```bash
30 | # Build `matchmaker` and its dependencies
31 | $ make deps
32 | $ make build
33 |
34 | # Initialize database configuration if you haven't already
35 | $ make db-init
36 |
37 | # Start the database
38 | $ make db-start
39 |
40 | # Run migrations against the running database (in another terminal)
41 | $ make db-setup
42 |
43 | # Start `matchmaker`
44 | $ make start
45 | ```
46 |
47 | ### Frontend
48 |
49 | ```bash
50 | $ make assets-deps
51 | $ make assets-build # or assets-watch if you're working on CSS/JS
52 | ```
53 |
54 | [simple-haskell]: https://img.shields.io/badge/Simple-Haskell-purple?style=flat-square
55 | [CI-badge]: https://img.shields.io/github/workflow/status/haskellfoundation/matchmaker/CI?style=flat-square
56 | [CI-url]: https://github.com/haskellfoundation/matchmaker/actions
57 |
--------------------------------------------------------------------------------
/test/fixtures.sql:
--------------------------------------------------------------------------------
1 | -- You can load this file into the database by running:
2 | -- $ psql "$PG_URI" < test/fixtures.sql
3 | INSERT INTO "organisations" ("organisation_id",
4 | "organisation_name",
5 | "created_at",
6 | "updated_at")
7 | VALUES ('b63ad088-a474-11eb-9236-5405db82c3cd',
8 | 'ghchq',
9 | '2021-04-10 01:00:00Z',
10 | '2021-04-11 01:00:00Z'
11 | );
12 |
13 | INSERT INTO "users" ("user_id",
14 | "username",
15 | "email",
16 | "display_name",
17 | "password",
18 | "created_at",
19 | "updated_at")
20 | VALUES ('44495a98-a475-11eb-94f3-5405db82c3cd',
21 | 'blue_devil',
22 | 'princess_jack@example.com',
23 | 'Princess Jack Moonshine',
24 | 'DRINK!',
25 | '2021-04-23 14:00:00Z',
26 | '2021-04-23 14:30:00Z'
27 | );
28 |
29 | INSERT INTO "user_organisation" ("user_organisation_id",
30 | "user_id",
31 | "organisation_id",
32 | "is_admin")
33 | VALUES ('c798acb4-3446-48c2-a8ec-08799535c1e6',
34 | '44495a98-a475-11eb-94f3-5405db82c3cd',
35 | 'b63ad088-a474-11eb-9236-5405db82c3cd',
36 | false
37 | );
38 |
39 | UPDATE "user_organisation" SET ("user_id",
40 | "organisation_id",
41 | "is_admin") =
42 | ROW('44495a98-a475-11eb-94f3-5405db82c3cd',
43 | 'b63ad088-a474-11eb-9236-5405db82c3cd',
44 | true)
45 | WHERE "user_organisation_id" = 'c798acb4-3446-48c2-a8ec-08799535c1e6';
46 |
--------------------------------------------------------------------------------
/src/Handler/Account/Create.hs:
--------------------------------------------------------------------------------
1 | module Handler.Account.Create where
2 |
3 | import Control.Monad.Except (throwError)
4 | import DB.Organisation
5 | import DB.User
6 | import Data.Time
7 | import Data.UUID.V4 (nextRandom)
8 | import Database.PostgreSQL.Transact (DBT)
9 | import ImportYesod
10 | import Model.UserModel
11 | import Web.Form
12 | import Web.Sessions
13 |
14 | postAccountCreateR :: Handler ()
15 | postAccountCreateR = do
16 | postParams <- getPostParams
17 | result <- runDB $ runExceptT $ postAccountCreate postParams
18 | case result of
19 | Left errors -> do
20 | putAssign "form_error" "true"
21 | handleFormErrors errors
22 | redirect SignupR
23 | Right _ ->
24 | redirect HomeR
25 |
26 | postAccountCreate
27 | :: [(Text, Text)]
28 | -> ExceptT (NonEmpty NewUserValidationError) (DBT IO) ()
29 | postAccountCreate postParams = do
30 | newUser <- liftIO $ validateNewUser postParams
31 | case newUser of
32 | FieldErrors errors -> throwError errors
33 | Result user@User{..} -> do
34 | orgId <- liftIO $ OrganisationId <$> nextRandom
35 | userOrgId <- liftIO $ UserOrganisationId <$> nextRandom
36 | timestamp <- liftIO getCurrentTime
37 | let org = newOrganisationFor orgId timestamp user
38 | lift $ insertUser user
39 | *> insertOrganisation org
40 | *> attachUser userId orgId userOrgId
41 | where
42 | newOrganisationFor :: OrganisationId -> UTCTime -> User -> Organisation
43 | newOrganisationFor organisationId createdAt user =
44 | Organisation
45 | { organisationId
46 | , organisationName = newOrgNameFrom user
47 | , createdAt
48 | , updatedAt = createdAt
49 | }
50 |
51 | newOrgNameFrom :: User -> Text
52 | newOrgNameFrom User{..} = userOrgNamePrefix <> username
53 |
54 | userOrgNamePrefix :: Text
55 | userOrgNamePrefix = "default_org_for_"
56 |
--------------------------------------------------------------------------------
/src/DB/Repository.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE QuasiQuotes #-}
2 | module DB.Repository where
3 |
4 | import Data.Aeson (FromJSON, ToJSON)
5 | import Data.Time (UTCTime)
6 | import Data.UUID (UUID)
7 | import Data.Vector (Vector)
8 | import Database.PostgreSQL.Entity
9 | import Database.PostgreSQL.Simple (FromRow, Only (Only), ToRow)
10 | import Database.PostgreSQL.Simple.FromField (FromField)
11 | import Database.PostgreSQL.Simple.ToField (ToField)
12 | import Database.PostgreSQL.Transact (DBT)
13 |
14 | import DB.Organisation (OrganisationId (..))
15 | import Database.PostgreSQL.Entity.Types
16 |
17 | newtype RepositoryId
18 | = RepositoryId { getRepositoryId :: UUID }
19 | deriving stock (Eq, Generic)
20 | deriving newtype (FromField, FromJSON, Show, ToField, ToJSON)
21 |
22 | data Repository
23 | = Repository { repositoryId :: RepositoryId
24 | , organisationId :: OrganisationId
25 | , repositoryName :: Text
26 | , repositoryDescription :: Text
27 | , repositoryURL :: Text
28 | , repositoryHomepage :: Maybe Text
29 | , createdAt :: UTCTime
30 | , updatedAt :: UTCTime
31 | }
32 | deriving stock (Eq, Generic, Show)
33 | deriving anyclass (FromRow, ToRow)
34 | deriving (Entity)
35 | via (GenericEntity '[TableName "repositories"] Repository)
36 |
37 | insertRepository :: Repository -> DBT IO ()
38 | insertRepository repo = insert @Repository repo
39 |
40 | getRepository :: RepositoryId -> DBT IO (Maybe Repository)
41 | getRepository repoId = selectById @Repository (Only repoId)
42 |
43 | getRepositoriesByOrg :: OrganisationId -> DBT IO (Vector Repository)
44 | getRepositoriesByOrg orgId = selectManyByField @Repository [field| organisation_id |] (Only orgId)
45 |
46 | getRepositoryByName :: Text -> DBT IO (Maybe Repository)
47 | getRepositoryByName name = selectOneByField [field| repository_name |] (Only name)
48 |
49 | deleteRepository :: RepositoryId -> DBT IO ()
50 | deleteRepository repoId = delete @Repository (Only repoId)
51 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | ghcid: dev
2 | dev: ## Start ghcid
3 | @ghcid --target lib:matchmaker --allow-eval --warnings
4 |
5 | start: ## Start the server
6 | @cabal run exe:matchmaker
7 |
8 | deps: ## Install the dependencies of the backend
9 | @command -v migrate >/dev/null || cabal install postgresql-simple-migration
10 | @cabal build --only-dependencies
11 |
12 | build: ## Build the project in fast mode
13 | @cabal build -O0
14 |
15 | clean: ## Remove compilation artifacts
16 | @cabal clean
17 |
18 | assets-deps: ## Install the dependencies of the frontend
19 | @cd assets/ && yarn
20 |
21 | assets-build: ## Build the web assets
22 | @cd assets/ && yarn webpack --config webpack/webpack.config.js
23 |
24 | assets-watch: ## Continuously rebuild the web assets
25 | @cd assets/ && yarn webpack -w --config webpack/webpack.config.js
26 |
27 | assets-clean: ## Remove JS artifacts
28 | @cd assets/ && rm -R node_modules
29 |
30 | db-init: ## Initialize the dev database
31 | @initdb -D _database
32 |
33 | db-start: ## Start the dev database
34 | @postgres -D _database
35 |
36 | db-setup: ## Setup the dev database
37 | @createdb matchmaker_dev
38 | @cabal exec -- migrate init "$(PG_CONNSTRING)" migrations
39 | @cabal exec -- migrate migrate "$(PG_CONNSTRING)" migrations
40 |
41 | db-reset: ## Reset the dev database
42 | @dropdb matchmaker_dev
43 | @make db-setup
44 |
45 | repl: ## Start a REPL
46 | @cabal repl
47 |
48 | test: ## Run the test suite
49 | @cabal test
50 |
51 | lint: ## Run the code linter (HLint)
52 | @find app test src -name "*.hs" | xargs -P $(PROCS) -I {} hlint --refactor-options="-i" --refactor {}
53 |
54 | format: style
55 | style: ## Run the code styler (stylish-haskell)
56 | @stylish-haskell -i -r src app test
57 |
58 | help:
59 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.* ?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
60 |
61 | UNAME := $(shell uname)
62 |
63 | ifeq ($(UNAME), Darwin)
64 | PROCS := $(shell sysctl -n hw.logicalcpu)
65 | else
66 | PROCS := $(shell nproc)
67 | endif
68 |
69 | .PHONY: all $(MAKECMDGOALS)
70 |
71 | .DEFAULT_GOAL := help
72 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | # Trigger the workflow on push or pull request, but only for the main branch
4 | on:
5 | pull_request:
6 | push:
7 | branches: ['main']
8 |
9 | jobs:
10 | cabal:
11 | name: ${{ matrix.os }} / ghc ${{ matrix.ghc }}
12 | runs-on: ${{ matrix.os }}
13 | strategy:
14 | matrix:
15 | os: [ubuntu-latest]
16 | cabal: ['3.4.0.0']
17 | ghc: ['8.10.4']
18 |
19 | steps:
20 | - uses: actions/checkout@v2
21 | if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/main'
22 |
23 | - uses: haskell/actions/setup@v1
24 | id: setup-haskell-cabal
25 | name: Setup Haskell
26 | with:
27 | ghc-version: ${{ matrix.ghc }}
28 | cabal-version: ${{ matrix.cabal }}
29 |
30 | - name: Configure environment
31 | run: |
32 | echo '/usr/lib/postgresql/12/bin/' >> $GITHUB_PATH
33 | echo "/nix/var/nix/profiles/per-user/$USER/profile/bin" >> "$GITHUB_PATH"
34 | echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
35 | echo 'NIX_PATH="nixpkgs=channel:nixos-unstable"' >> "$GITHUB_ENV"
36 | echo '$HOME/.ghcup/bin' >> $GITHUB_PATH
37 | echo 'HOME/.cabal/bin' >> $GITHUB_PATH
38 | echo 'HOME/.local/bin' >> $GITHUB_PATH
39 |
40 | - name: Install Nix
41 | run: |
42 | ./.github/workflows/install-nix.sh
43 |
44 | - name: Configure
45 | run: |
46 | cabal configure --enable-tests --test-show-details=direct
47 |
48 | - name: Freeze
49 | run: |
50 | nix-shell --run 'cabal freeze'
51 |
52 | - uses: actions/cache@v2
53 | name: Cache ~/.cabal/store
54 | with:
55 | path: ${{ steps.setup-haskell-cabal.outputs.cabal-store }}
56 | key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}
57 |
58 | - name: Running hlint
59 | run: nix-shell --run './.github/workflows/hlint-runner.sh'
60 |
61 | - name: Running stylish-haskell
62 | run: nix-shell --run './.github/workflows/stylish-haskell-runner.sh'
63 |
64 | - name: Install dependencies
65 | run: |
66 | nix-shell --run 'make deps'
67 |
68 | - name: Build
69 | run: |
70 | nix-shell --run 'make build'
71 |
72 | - name: Test
73 | run: |
74 | nix-shell --run 'make test'
75 |
--------------------------------------------------------------------------------
/src/Model/UserModel.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE StrictData #-}
2 | module Model.UserModel where
3 |
4 | import DB.User
5 | import qualified Data.Map as M
6 | import Data.Password.Argon2 (Password, mkPassword)
7 | import qualified Data.Text as T
8 | import Data.Time
9 | import Data.UUID.V4 (nextRandom)
10 | import Web.Form
11 | import Web.Sessions
12 |
13 | data NewUserValidationError
14 | = EmptyUsername
15 | | EmptyDisplayName
16 | | TooShortPassword
17 | | InvalidEmailAddress
18 | | MissingField Text
19 | deriving Show
20 |
21 | instance ErrorToAssign NewUserValidationError where
22 | putErrorAssign err =
23 | case err of
24 | EmptyUsername -> putAssign "form_error_username" "Username cannot be empty"
25 | EmptyDisplayName -> putAssign "form_error_displayname" "Display name cannot be empty"
26 | TooShortPassword -> putAssign "form_error_password" "Password cannot be smaller than 8 characters"
27 | InvalidEmailAddress -> putAssign "form_error_email" "Email address is invalid"
28 | MissingField field -> putAssign ("form_error_" <> field) ("Missing required field: " <> field)
29 |
30 | validateNewUser :: [(Text,Text)] -> IO (FormValidation NewUserValidationError User)
31 | validateNewUser params = do
32 | let paramMap = M.fromList params
33 | ts <- getCurrentTime
34 | userId <- UserId <$> nextRandom
35 | hashedPassword <- traverse hashPassword $ validateShortPassword =<< lookupFormField MissingField "password" paramMap
36 | pure $
37 | User userId
38 | <$> (validateUsername =<< lookupFormField MissingField "username" paramMap)
39 | <*> (validateEmailAddress =<< lookupFormField MissingField "email" paramMap)
40 | <*> (validateDisplayName =<< lookupFormField MissingField "displayname" paramMap)
41 | <*> hashedPassword
42 | <*> pure ts
43 | <*> pure ts
44 |
45 | validateUsername :: Text -> FormValidation NewUserValidationError Text
46 | validateUsername name = if T.null name then fieldError EmptyUsername else pure name
47 |
48 | validateDisplayName:: Text -> FormValidation NewUserValidationError Text
49 | validateDisplayName name = if T.null name then fieldError EmptyDisplayName else pure name
50 |
51 | validateShortPassword :: Text -> FormValidation NewUserValidationError Password
52 | validateShortPassword password =
53 | if T.length password < 8
54 | then fieldError TooShortPassword
55 | else Result $ mkPassword password
56 |
57 | validateEmailAddress :: Text -> FormValidation NewUserValidationError Text
58 | validateEmailAddress email =
59 | if not . T.isInfixOf "@" $ email
60 | then fieldError InvalidEmailAddress
61 | else pure email
62 |
--------------------------------------------------------------------------------
/src/Templates.hs:
--------------------------------------------------------------------------------
1 | module Templates where
2 |
3 | import qualified Data.HashMap.Strict as HM
4 | import qualified Data.HashMap.Strict as HashMap
5 | import Foundation
6 | import System.IO.Error (tryIOError)
7 | import Templates.Types (ModuleName (..), TemplateAssigns (..),
8 | TemplateName (..))
9 | import Text.Ginger (GVal, Source, SourceName, ToGVal (..), makeContextHtml,
10 | parseGingerFile, runGinger)
11 | import Text.Ginger.Html (htmlSource)
12 | import Web.Helpers (debug)
13 | import Web.Sessions (UserAssigns (UserAssigns), getAllUserAssigns, popAssign)
14 | import Web.Types (MatchmakerError)
15 | import Yesod.Core
16 |
17 | render :: ModuleName -> TemplateName -> TemplateAssigns -> Handler Html
18 | render (ModuleName moduleName) (TemplateName templateName) assigns = do
19 | let templatePath = "./src/Templates/" <> moduleName <> "/" <> templateName <> ".html"
20 | mUserAssigns <- getAllUserAssigns
21 | let (TemplateAssigns hm) = mkAssigns assigns mUserAssigns
22 | debug ("Assigns: " <> show hm)
23 | let contextLookup = flip scopeLookup hm
24 | let context = makeContextHtml contextLookup
25 | eTemplate <- liftIO $ parseGingerFile resolver (toString templatePath)
26 | case eTemplate of
27 | Left err -> pure $ show err
28 | Right template -> do
29 | popAssign "flash_alert_info"
30 | popAssign "flash_alert_error"
31 | pure . preEscapedToMarkup . htmlSource $ runGinger context template
32 |
33 | mkAssigns :: TemplateAssigns -> Maybe UserAssigns -> TemplateAssigns
34 | mkAssigns (TemplateAssigns templateAssigns) (Just (UserAssigns userAssigns)) =
35 | TemplateAssigns $ HM.union templateAssigns userAssigns
36 | mkAssigns ta Nothing = ta
37 |
38 | resolver :: SourceName -> IO (Maybe Source)
39 | resolver templatePath = do
40 | e <- liftIO $ tryIOError $ readFile templatePath
41 | case e of
42 | Right contents -> pure (Just contents)
43 | Left _ -> pure Nothing
44 |
45 | -- Wrapper around HashMap.lookup that applies toGVal to the value found.
46 | -- Any value referenced in a template, returned from within a template, or used
47 | -- in a template context, will be a GVal
48 | scopeLookup ::
49 | (Hashable k, Eq k, ToGVal m b) =>
50 | k ->
51 | HashMap.HashMap k b ->
52 | GVal m
53 | scopeLookup key context = toGVal $ HashMap.lookup key context
54 |
55 | errorHandler ::
56 | HasCallStack =>
57 | MatchmakerError ->
58 | Handler Html
59 | errorHandler err = do
60 | let assigns = TemplateAssigns $ HM.fromList [("error", toStrict $ show err), ("stacktrace", toText $ prettyCallStack callStack)]
61 | render (ModuleName "Error") (TemplateName "500") assigns
62 |
--------------------------------------------------------------------------------
/src/Web/Sessions/Server.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE DeriveFunctor #-}
2 |
3 | -- |
4 | -- A session has 2 parts in the matchmaker architecture. The first is managed by yesod, which
5 | -- is the client session. These are cookies (yesod handles encryption) that live on the users
6 | -- browser. We keep the data on the client side minimal, it is just a @UUID@ that corresponds
7 | -- to some state that we maintain on the server. This is the second part of the session; an in
8 | -- memory data structure (@HM.HashMap@ for now) that keeps track of various pieces of user state.
9 | -- The functions in this section are helpers for initializing, modifying, and cleaning up the
10 | -- state that we maintain on the server.
11 | module Web.Sessions.Server where
12 |
13 | import Control.Concurrent
14 | import qualified Data.HashMap.Strict as HM
15 | import Data.Time (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)
16 | import Data.UUID (UUID, fromText)
17 | import Data.UUID.V4
18 |
19 | data Session a = Session
20 | { sessionId :: UUID,
21 | sessionValidUntil :: UTCTime,
22 | sessionContent :: a
23 | }
24 | deriving (Show, Eq, Functor)
25 |
26 | type SessionManager a = TVar (HashMap UUID (Session a))
27 |
28 | sessionTTL :: NominalDiffTime
29 | sessionTTL = 36000
30 |
31 | makeSession :: a -> IO (Session a)
32 | makeSession content = do
33 | uuid <- nextRandom
34 | now <- getCurrentTime
35 | let validUntil = addUTCTime sessionTTL now
36 | pure $ Session uuid validUntil content
37 |
38 | insertServerSession :: SessionManager a -> Session a -> IO ()
39 | insertServerSession sessions sessionVal =
40 | atomically $ modifyTVar' sessions $ HM.insert (sessionId sessionVal) sessionVal
41 |
42 | getServerSession :: SessionManager a -> Text -> IO (Maybe (Session a))
43 | getServerSession sessions sid =
44 | fmap join $ forM (fromText sid) $ \sessionId -> do
45 | s <- readTVarIO sessions
46 | pure $ HM.lookup sessionId s
47 |
48 | upsertServerSession ::
49 | SessionManager a ->
50 | Maybe Text ->
51 | (Maybe (Session a) -> a) ->
52 | IO (Session a)
53 | upsertServerSession manager mSid f = do
54 | mSessionAssigns <- join <$> traverse (getServerSession manager) mSid
55 | s <- makeSession . f $ mSessionAssigns
56 | insertServerSession manager s
57 | pure s
58 |
59 | modifyServerSession ::
60 | SessionManager a ->
61 | Text ->
62 | (a -> a) ->
63 | IO (Maybe (Session a))
64 | modifyServerSession manager sid f = do
65 | mSessionAssigns <- getServerSession manager sid
66 | forM mSessionAssigns $ \sessionAssigns -> do
67 | s <- makeSession . f $ sessionContent sessionAssigns
68 | insertServerSession manager s
69 | pure s
70 |
71 | createSessionManager :: IO (SessionManager a)
72 | createSessionManager = do
73 | storage <- newTVarIO HM.empty
74 | forkIO $ maintainServerSessions storage
75 | pure storage
76 |
77 | maintainServerSessions :: SessionManager a -> IO ()
78 | maintainServerSessions sessions = do
79 | now <- getCurrentTime
80 | atomically $ modifyTVar' sessions $ \m -> HM.filter (stillValid now) m
81 | threadDelay 1000000
82 | maintainServerSessions sessions
83 | where
84 | stillValid currTime sess = sessionValidUntil sess > currTime
85 |
86 |
--------------------------------------------------------------------------------
/test/DB/OrganisationSpec.hs:
--------------------------------------------------------------------------------
1 |
2 | {-# LANGUAGE OverloadedLists #-}
3 | {-# OPTIONS_GHC -Wno-unused-imports #-}
4 |
5 | module DB.OrganisationSpec where
6 |
7 | import Data.Password.Argon2
8 | import Data.UUID.V4
9 | import Relude.Unsafe (read)
10 | import Test.Hspec (Spec)
11 | import Test.Hspec.DB (describeDB, itDB)
12 | import Test.Hspec.Expectations.Lifted (expectationFailure, shouldReturn)
13 |
14 | import DB.Organisation (Organisation (..), OrganisationId (..),
15 | UserOrganisationId (..), attachUser, getAdmins,
16 | getAllUserOrganisations, getOrganisationByName,
17 | getUserOrganisation, getUserOrganisationById, getUsers,
18 | insertOrganisation, makeAdmin)
19 | import DB.SpecHelpers (migrate)
20 | import DB.User
21 |
22 | user1 :: User
23 | user1 =
24 | let userId = UserId (read "4e511d7a-a464-11eb-b30b-5405db82c3cd")
25 | username = "pmpc"
26 | email = "pmpc@example.com"
27 | displayName = "Plonk McPlonkface"
28 | password = PasswordHash "foobar2000"
29 | createdAt = read "2021-04-23 10:00:00 UTC"
30 | updatedAt = read "2021-04-23 10:00:00 UTC"
31 | in User{..}
32 |
33 | user2 :: User
34 | user2 =
35 | let userId = UserId (read "44495a98-a475-11eb-94f3-5405db82c3cd")
36 | username = "blue_devil"
37 | email = "princess_jack@example.com"
38 | displayName = "Princess Jack Moonshine"
39 | password = PasswordHash "DRINK!"
40 | createdAt = read "2021-04-23 14:00:00 UTC"
41 | updatedAt = read "2021-04-23 14:30:00 UTC"
42 | in User{..}
43 |
44 | organisation1 :: Organisation
45 | organisation1 =
46 | let organisationId = OrganisationId (read "6e9b2ff8-a469-11eb-b05c-5405db82c3cd")
47 | organisationName = "haskell-servant"
48 | createdAt = read "2021-03-30 01:00:00 UTC"
49 | updatedAt = read "2021-03-30 01:00:00 UTC"
50 | in Organisation{..}
51 |
52 | organisation2 :: Organisation
53 | organisation2 =
54 | let organisationId = OrganisationId (read "b63ad088-a474-11eb-9236-5405db82c3cd")
55 | organisationName = "ghchq"
56 | createdAt = read "2021-04-10 01:00:00 UTC"
57 | updatedAt = read "2021-04-11 01:00:00 UTC"
58 | in Organisation{..}
59 |
60 | spec :: Spec
61 | spec = describeDB migrate "users" $ do
62 | itDB "Attach user1 to organisation1" $ do
63 | let uid = userId user1
64 | let oid = organisationId organisation1
65 | let uoId = UserOrganisationId (read "e801f560-a4dd-11eb-844b-5405db82c3cd")
66 | insertOrganisation organisation1
67 | insertUser user1
68 | attachUser uid oid uoId
69 | uos <- getUserOrganisationById uoId
70 | getUserOrganisation uid oid
71 | `shouldReturn` uos
72 | itDB "Look for admins in the organisation" $ do
73 | let uid = userId user2
74 | let oid = organisationId organisation2
75 | let uoId = UserOrganisationId (read "f865652c-a4dd-11eb-8a43-5405db82c3cd")
76 | insertOrganisation organisation2
77 | insertUser user2
78 | attachUser uid oid uoId
79 | makeAdmin uid oid
80 | uo <- getUserOrganisation uid oid
81 | print uo
82 | getAdmins (organisationId organisation2)
83 | `shouldReturn` [user2]
84 |
--------------------------------------------------------------------------------
/src/DB/User.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE DerivingVia #-}
2 | {-# LANGUAGE QuasiQuotes #-}
3 | {-# LANGUAGE UndecidableInstances #-}
4 | {-# OPTIONS_GHC -fno-warn-orphans -Wno-redundant-constraints #-}
5 | module DB.User where
6 |
7 | import Data.Aeson (FromJSON (..), ToJSON (..))
8 | import Data.Password.Argon2 (Argon2, Password, PasswordCheck (..), PasswordHash)
9 | import qualified Data.Password.Argon2 as Argon2
10 | import Data.Time (UTCTime)
11 | import Data.UUID (UUID)
12 | import qualified Data.UUID as UUID
13 | import Database.PostgreSQL.Entity
14 | import Database.PostgreSQL.Entity.Types
15 | import Database.PostgreSQL.Simple (Only (Only))
16 | import Database.PostgreSQL.Simple.FromField (FromField (..))
17 | import Database.PostgreSQL.Simple.FromRow (FromRow (..))
18 | import Database.PostgreSQL.Simple.ToField (ToField (..))
19 | import Database.PostgreSQL.Simple.ToRow (ToRow (..))
20 | import Database.PostgreSQL.Transact (DBT)
21 | import GHC.TypeLits (ErrorMessage (..), TypeError)
22 |
23 | newtype UserId
24 | = UserId { getUserId :: UUID }
25 | deriving stock (Eq, Generic)
26 | deriving newtype (FromField, FromJSON, Show, ToField, ToJSON)
27 |
28 | instance ToText UserId where
29 | toText (UserId uuid) = UUID.toText uuid
30 |
31 | data User
32 | = User { userId :: UserId
33 | , username :: Text
34 | , email :: Text
35 | , displayName :: Text
36 | , password :: PasswordHash Argon2
37 | , createdAt :: UTCTime
38 | , updatedAt :: UTCTime
39 | }
40 | deriving stock (Eq, Generic, Show)
41 | deriving anyclass (FromRow, ToRow)
42 | deriving (Entity)
43 | via (GenericEntity '[TableName "users"] User)
44 |
45 | -- | Type error! Do not use 'toJSON' on a 'Password'!
46 | instance TypeError (ErrMsg "JSON") => ToJSON Password where
47 | toJSON = error "unreachable"
48 |
49 | type ErrMsg e = 'Text "Warning! Tried to convert plain-text Password to " ':<>: 'Text e ':<>: 'Text "!"
50 | ':$$: 'Text " This is likely a security leak. Please make sure whether this was intended."
51 | ':$$: 'Text " If this is intended, please use 'unsafeShowPassword' before converting to " ':<>: 'Text e
52 | ':$$: 'Text ""
53 |
54 | instance FromJSON Password where
55 | parseJSON = fmap Argon2.mkPassword . parseJSON
56 |
57 | deriving via Text instance ToField (PasswordHash a)
58 | deriving via Text instance FromField (PasswordHash a)
59 |
60 | -- Database operations
61 |
62 | hashPassword :: (MonadIO m) => Password -> m (PasswordHash Argon2)
63 | hashPassword = Argon2.hashPassword
64 |
65 | validatePassword :: Password -> PasswordHash Argon2 -> Bool
66 | validatePassword inputPassword hashedPassword =
67 | Argon2.checkPassword inputPassword hashedPassword == PasswordCheckSuccess
68 |
69 | insertUser :: HasCallStack => User -> DBT IO ()
70 | insertUser user = insert @User user
71 |
72 | getUserById :: HasCallStack => UserId -> DBT IO (Maybe User)
73 | getUserById userId = selectById (Only userId)
74 |
75 | getUserByUsername :: HasCallStack => Text -> DBT IO (Maybe User)
76 | getUserByUsername username = selectOneByField [field| username |] (Only username)
77 |
78 | getUserByEmail :: HasCallStack => Text -> DBT IO (Maybe User)
79 | getUserByEmail email = selectOneByField [field| email |] (Only email)
80 |
81 | deleteUser :: HasCallStack => UserId -> DBT IO ()
82 | deleteUser userId = delete @User (Only userId)
83 |
--------------------------------------------------------------------------------
/src/Environment.hs:
--------------------------------------------------------------------------------
1 | {-# LANGUAGE StrictData #-}
2 | module Environment
3 | ( MatchmakerEnv (..)
4 | , PoolConfig(..)
5 | , getMatchmakerEnv
6 | ) where
7 |
8 |
9 | import Control.Monad.Logger (LogLevel (..))
10 | import Data.Time (NominalDiffTime)
11 | import qualified Database.PostgreSQL.Simple as PG
12 | import Env (AsUnread (unread), Error (..), Parser, Reader, help, parse, str,
13 | var)
14 | import Prelude hiding (Reader)
15 |
16 | data MatchmakerEnv
17 | = MatchmakerEnv { matchmakerPgConfig :: PG.ConnectInfo
18 | , matchmakerPoolConfig :: PoolConfig
19 | , matchmakerHttpPort :: Word16
20 | , matchmakerLogLevel :: LogLevel
21 | }
22 | deriving (Show)
23 |
24 | data PoolConfig
25 | = PoolConfig { subPools :: Int
26 | , connectionTimeout :: NominalDiffTime
27 | , connections :: Int
28 | }
29 | deriving (Show)
30 |
31 |
32 | parseConnectInfo :: Parser Error PG.ConnectInfo
33 | parseConnectInfo =
34 | PG.ConnectInfo <$> var str "DB_HOST" (help "PostgreSQL host")
35 | <*> var port "DB_PORT" (help "PostgreSQL port")
36 | <*> var str "DB_USER" (help "PostgreSQL user")
37 | <*> var str "DB_PASSWORD" (help "PostgreSQL password")
38 | <*> var str "DB_DATABASE" (help "Control-Plane database")
39 |
40 | parsePoolConfig :: Parser Error PoolConfig
41 | parsePoolConfig =
42 | PoolConfig <$> var (int >=> nonNegative) "DB_SUB_POOLS" (help "Number of sub-pools")
43 | <*> var timeout "DB_TIMEOUT" (help "Timeout for each connection")
44 | <*> var (int >=> nonNegative) "DB_POOL_CONNECTIONS" (help "Number of connections per sub-pool")
45 |
46 | parsePort :: Parser Error Word16
47 | parsePort = var port "MATCHMAKER_PORT" (help "HTTP Port for Matchmaker")
48 |
49 | parseLogLevel :: Parser Error LogLevel
50 | parseLogLevel = var readLogLevel "MATCHMAKER_LOG_LEVEL" (help "Log level for Matchmaker")
51 |
52 | parseConfig :: Parser Error MatchmakerEnv
53 | parseConfig =
54 | MatchmakerEnv
55 | <$> parseConnectInfo
56 | <*> parsePoolConfig
57 | <*> parsePort
58 | <*> parseLogLevel
59 |
60 | getMatchmakerEnv :: IO MatchmakerEnv
61 | getMatchmakerEnv = Env.parse id parseConfig
62 |
63 | -- Env parser helpers
64 |
65 | int :: Reader Error Int
66 | int i =
67 | case readMaybe i of
68 | Nothing -> Left . unread . show $ i
69 | Just i' -> Right i'
70 |
71 | port :: Reader Error Word16
72 | port p =
73 | case int p of
74 | Left err -> Left err
75 | Right intPort ->
76 | if intPort >= 1 && intPort <= 65535
77 | then Right $ fromIntegral intPort
78 | else Left . unread . show $ p
79 |
80 | nonNegative :: Int -> Either Error Int
81 | nonNegative nni =
82 | if nni >= 0
83 | then Right nni
84 | else Left . unread . show $ nni
85 |
86 | timeout :: Reader Error NominalDiffTime
87 | timeout t = second fromIntegral (int >=> nonNegative $ t)
88 |
89 | readLogLevel :: Reader Error LogLevel
90 | readLogLevel ll = do
91 | ll' <- str ll
92 | case ll' of
93 | "debug" -> Right LevelDebug
94 | "info" -> Right LevelInfo
95 | "warn" -> Right LevelWarn
96 | "error" -> Right LevelError
97 | "silent" -> Right $ LevelOther "silent"
98 | loglevel -> Left . unread $ loglevel <> " is not a valid option for MATCHMAKER_LOG_LEVEL"
99 |
--------------------------------------------------------------------------------
/src/Web/Sessions.hs:
--------------------------------------------------------------------------------
1 | module Web.Sessions
2 | ( UserAssigns (..),
3 | module Web.Sessions,
4 | )
5 | where
6 |
7 | import DB.User (UserId (..))
8 | import qualified Data.HashMap.Strict as HM
9 | import qualified Data.UUID as UUID
10 | import Foundation
11 | import Prelude
12 | import Web.Sessions.Server (Session (..), getServerSession, modifyServerSession,
13 | upsertServerSession)
14 | import Web.Sessions.Types (UserAssigns (..))
15 | import Yesod.Core
16 |
17 | lookupClientSession :: MonadHandler m => Text -> m (Maybe Text)
18 | lookupClientSession = lookupSession
19 |
20 | setClientSession :: MonadHandler m => Text -> Text -> m ()
21 | setClientSession = setSession
22 |
23 | getClientSessions :: MonadHandler m => m SessionMap
24 | getClientSessions = getSession
25 |
26 | clearClientSession :: MonadHandler m => Text -> m ()
27 | clearClientSession = deleteSession
28 |
29 | clientSessionIdentifier :: Text
30 | clientSessionIdentifier = "sid"
31 |
32 | markAuthenticated :: UserId -> Handler ()
33 | markAuthenticated uId = putAssign "user_id" (toText uId)
34 |
35 | getUserIdFromSession :: Handler (Maybe UserId)
36 | getUserIdFromSession = readAssign "user_id" (fmap UserId . UUID.fromText)
37 |
38 | getAllUserAssigns :: Handler (Maybe UserAssigns)
39 | getAllUserAssigns = do
40 | Foundation {appSessionManager} <- getYesod
41 | mClientSession <- lookupSession clientSessionIdentifier
42 | liftIO . fmap join $
43 | forM mClientSession $ \sid -> do
44 | mSession <- getServerSession appSessionManager sid
45 | pure $ sessionContent <$> mSession
46 |
47 | putAssign :: Text -> Text -> Handler ()
48 | putAssign key value = do
49 | Foundation {appSessionManager} <- getYesod
50 | mClientSid <- lookupSession clientSessionIdentifier
51 |
52 | serverSession <-
53 | liftIO $
54 | upsertServerSession appSessionManager mClientSid (upsertUserAssigns key value)
55 |
56 | void $ setClientSession clientSessionIdentifier (UUID.toText $ sessionId serverSession)
57 |
58 |
59 | readAssign :: Text -> (Text -> Maybe a) -> Handler (Maybe a)
60 | readAssign key f = do
61 | assign <- fetchAssign key
62 | pure $ f =<< assign
63 |
64 | fetchAssign :: Text -> Handler (Maybe Text)
65 | fetchAssign key = do
66 | Foundation {appSessionManager} <- getYesod
67 | mClientSession <- lookupSession clientSessionIdentifier
68 | liftIO . fmap join $
69 | forM mClientSession $ \sid -> do
70 | mSession <- getServerSession appSessionManager sid
71 | pure $ mSession >>= lookupUserAssign key . sessionContent
72 |
73 | popAssign :: Text -> Handler (Maybe Text)
74 | popAssign key = do
75 | Foundation {appSessionManager} <- getYesod
76 | mClientSession <- lookupSession clientSessionIdentifier
77 |
78 | liftIO . fmap join $
79 | forM mClientSession $ \sid -> do
80 | mSession <- getServerSession appSessionManager sid
81 | let mAssign = mSession >>= lookupUserAssign key . sessionContent
82 | forM mAssign $ \assign ->
83 | modifyServerSession appSessionManager sid (removeUserAssign key) >> pure assign
84 |
85 | upsertUserAssigns :: Text -> Text -> Maybe (Session UserAssigns) -> UserAssigns
86 | upsertUserAssigns key value Nothing = UserAssigns $ HM.insert key value HM.empty
87 | upsertUserAssigns key value (Just Session {..}) =
88 | UserAssigns
89 | . HM.insert key value
90 | . getUserAssigns
91 | $ sessionContent
92 |
93 | removeUserAssign :: Text -> UserAssigns -> UserAssigns
94 | removeUserAssign key = UserAssigns . HM.delete key . getUserAssigns
95 |
96 | lookupUserAssign :: Text -> UserAssigns -> Maybe Text
97 | lookupUserAssign key = HM.lookup key . getUserAssigns
98 |
--------------------------------------------------------------------------------
/assets/layout.jinja.template:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 28 | Broadcast your calls to potential contributors! 29 |
30 | 32 | Register your project 33 | 39 | 40 |56 | Find open-source projects that need your help! 57 |
58 | 60 | Browse projects 61 | 67 | 68 |
75 |