├── .gitignore ├── .hlint.yaml ├── .stylish-haskell.yaml ├── CONTRIBUTING.md ├── ChangeLog.md ├── LICENSE ├── README.md ├── account ├── backend │ ├── LICENSE │ ├── rhyolite-account-backend.cabal │ └── src │ │ └── Rhyolite │ │ └── Backend │ │ ├── Account.hs │ │ └── Account │ │ ├── Db.hs │ │ └── Notify.hs └── types │ ├── LICENSE │ ├── rhyolite-account-types.cabal │ └── src │ └── Rhyolite │ └── Account.hs ├── backend ├── Rhyolite │ └── Backend │ │ ├── App.hs │ │ └── WebSocket.hs └── rhyolite-backend.cabal ├── beam ├── db │ ├── LICENSE │ ├── rhyolite-beam-db.cabal │ └── src │ │ └── Rhyolite │ │ └── DB │ │ ├── Beam.hs │ │ └── Beam │ │ └── Types.hs ├── orphans │ ├── LICENSE │ ├── rhyolite-beam-orphans.cabal │ └── src │ │ └── Rhyolite │ │ └── DB │ │ └── Beam │ │ └── Orphans.hs └── task │ ├── backend │ ├── LICENSE │ ├── rhyolite-beam-task-worker-backend.cabal │ ├── src │ │ └── Rhyolite │ │ │ └── Task │ │ │ └── Beam │ │ │ └── Worker.hs │ └── test │ │ ├── Test.hs │ │ ├── Types.hs │ │ └── Utils.hs │ └── types │ ├── LICENSE │ ├── rhyolite-beam-task-worker-types.cabal │ └── src │ └── Rhyolite │ └── Task │ └── Beam.hs ├── cabal.project ├── common ├── Data │ └── Vessel │ │ └── Void.hs ├── Rhyolite │ ├── Aeson │ │ └── Orphans.hs │ ├── Api.hs │ ├── App.hs │ ├── Concurrent.hs │ ├── Route.hs │ ├── Vessel │ │ ├── App.hs │ │ ├── AuthMapV.hs │ │ ├── AuthenticatedV.hs │ │ ├── ErrorV.hs │ │ ├── ErrorV │ │ │ └── Internal.hs │ │ ├── Path.hs │ │ └── Types.hs │ └── WebSocket.hs └── rhyolite-common.cabal ├── default.nix ├── dep ├── beam-automigrate │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── bytestring-aeson-orphans │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── default.nix ├── gargoyle │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── monoid-map │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── obelisk │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── postgresql-lo-stream │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── postgresql-simple-interpolate │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── push-notifications │ ├── default.nix │ ├── github.json │ └── thunk.nix └── vessel │ ├── default.nix │ ├── github.json │ └── thunk.nix ├── email ├── CHANGELOG.md ├── LICENSE ├── email.css ├── mime-mail-orphans │ ├── LICENSE │ ├── mime-mail-orphans.cabal │ └── src │ │ └── Network │ │ └── Mail │ │ └── Mime │ │ └── Orphans.hs ├── rhyolite-email.cabal └── src │ └── Rhyolite │ ├── Email.hs │ └── Email │ └── Orphans.hs ├── frontend ├── Rhyolite │ └── Frontend │ │ ├── App.hs │ │ ├── Auth.hs │ │ ├── Auth │ │ └── App.hs │ │ └── Cookie.hs └── rhyolite-frontend.cabal ├── gen-docs.sh ├── notify-listen ├── notify-listen-beam │ ├── CHANGELOG.md │ ├── LICENSE │ ├── rhyolite-notify-listen-beam.cabal │ └── src │ │ └── Rhyolite │ │ └── DB │ │ └── NotifyListen │ │ └── Beam.hs └── notify-listen │ ├── rhyolite-notify-listen.cabal │ └── src │ └── Rhyolite │ └── DB │ └── NotifyListen.hs ├── psql-extras ├── psql-serializable │ ├── LICENSE │ ├── psql-serializable.cabal │ └── src │ │ └── Database │ │ └── PostgreSQL │ │ └── Serializable.hs ├── psql-simple-beam │ ├── CHANGELOG.md │ ├── LICENSE │ ├── psql-simple-beam.cabal │ └── src │ │ └── Database │ │ └── PostgreSQL │ │ └── Simple │ │ └── Beam.hs └── psql-simple-class │ ├── CHANGELOG.md │ ├── LICENSE │ ├── psql-simple-class.cabal │ └── src │ └── Database │ └── PostgreSQL │ └── Simple │ └── Class.hs ├── release.nix ├── rhyolite.svg ├── semimap ├── semimap.cabal └── src │ └── Rhyolite │ └── SemiMap.hs ├── signed-data ├── signed-data-clientsession │ ├── LICENSE │ ├── signed-data-clientsession.cabal │ └── src │ │ └── Data │ │ └── Signed │ │ └── ClientSession.hs └── signed-data │ ├── CHANGELOG.md │ ├── LICENSE │ ├── signed-data.cabal │ └── src │ └── Data │ └── Signed.hs ├── test ├── rhyolite-test-suite.cabal └── src-bin │ └── test.hs └── widgets ├── CHANGELOG.md ├── LICENSE ├── rhyolite-widgets.cabal └── src └── Reflex └── Dom └── Widget ├── ExtensibleList.hs └── Form.hs /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | dist-newstyle/ 3 | result 4 | result-* 5 | *.project.local 6 | .ghc.* 7 | .attr-cache 8 | tags 9 | TAGS 10 | beam/task/backend/psql-test* 11 | -------------------------------------------------------------------------------- /.hlint.yaml: -------------------------------------------------------------------------------- 1 | # HLint configuration file 2 | # https://github.com/ndmitchell/hlint 3 | ########################## 4 | 5 | # This file contains a template configuration file, which is typically 6 | # placed as .hlint.yaml in the root of your project 7 | 8 | 9 | # Specify additional command line arguments 10 | # 11 | - arguments: [--color, --cpp-simple, -XTypeApplications, -XRecursiveDo] 12 | 13 | 14 | # Control which extensions/flags/modules/functions can be used 15 | # 16 | # - extensions: 17 | # - name: [TypeApplications] # only these listed extensions can be used 18 | # - default: false # all extension are banned by default 19 | # - {name: CPP, within: CrossPlatform} # CPP can only be used in a given module 20 | # 21 | # - flags: 22 | # - {name: -w, within: []} # -w is allowed nowhere 23 | # 24 | # - modules: 25 | # - {name: [Data.Set, Data.HashSet], as: Set} # if you import Data.Set qualified, it must be as 'Set' 26 | # - {name: Control.Arrow, within: []} # Certain modules are banned entirely 27 | # 28 | - functions: 29 | - {name: unsafePerformIO, within: []} # unsafePerformIO can only appear in no modules 30 | - {name: undefined, within: []} # unsafePerformIO can only appear in no modules 31 | 32 | 33 | # Add custom hints for this project 34 | # 35 | # Will suggest replacing "wibbleMany [myvar]" with "wibbleOne myvar" 36 | # - error: {lhs: "wibbleMany [x]", rhs: wibbleOne x} 37 | 38 | 39 | # Turn on hints that are off by default 40 | # 41 | # Ban "module X(module X) where", to require a real export list 42 | # - warn: {name: Use explicit module export list} 43 | # 44 | # Replace a $ b $ c with a . b $ c 45 | # - group: {name: dollar, enabled: true} 46 | # 47 | # Generalise map to fmap, ++ to <> 48 | # - group: {name: generalise, enabled: true} 49 | 50 | 51 | # Ignore some builtin hints 52 | - ignore: {name: Redundant do} 53 | - ignore: {name: Use camelCase} 54 | - ignore: {name: Use if} 55 | - ignore: {name: Use <$>} 56 | - ignore: {name: "Use ?~"} 57 | # - ignore: {name: Use const, within: SpecialModule} # Only within certain modules 58 | 59 | 60 | # Define some custom infix operators 61 | # - fixity: infixr 3 ~^#^~ 62 | 63 | 64 | # To generate a suitable file for HLint do: 65 | # $ hlint --default > .hlint.yaml 66 | -------------------------------------------------------------------------------- /.stylish-haskell.yaml: -------------------------------------------------------------------------------- 1 | # stylish-haskell configuration file 2 | # ================================== 3 | 4 | # The stylish-haskell tool is mainly configured by specifying steps. These steps 5 | # are a list, so they have an order, and one specific step may appear more than 6 | # once (if needed). Each file is processed by these steps in the given order. 7 | steps: 8 | # Convert some ASCII sequences to their Unicode equivalents. This is disabled 9 | # by default. 10 | # - unicode_syntax: 11 | # # In order to make this work, we also need to insert the UnicodeSyntax 12 | # # language pragma. If this flag is set to true, we insert it when it's 13 | # # not already present. You may want to disable it if you configure 14 | # # language extensions using some other method than pragmas. Default: 15 | # # true. 16 | # add_language_pragma: true 17 | 18 | # Import cleanup 19 | - imports: 20 | # There are different ways we can align names and lists. 21 | # 22 | # - global: Align the import names and import list throughout the entire 23 | # file. 24 | # 25 | # - file: Like global, but don't add padding when there are no qualified 26 | # imports in the file. 27 | # 28 | # - group: Only align the imports per group (a group is formed by adjacent 29 | # import lines). 30 | # 31 | # - none: Do not perform any alignment. 32 | # 33 | # Default: global. 34 | align: none 35 | 36 | # Folowing options affect only import list alignment. 37 | # 38 | # List align has following options: 39 | # 40 | # - after_alias: Import list is aligned with end of import including 41 | # 'as' and 'hiding' keywords. 42 | # 43 | # > import qualified Data.List as List (concat, foldl, foldr, head, 44 | # > init, last, length) 45 | # 46 | # - with_alias: Import list is aligned with start of alias or hiding. 47 | # 48 | # > import qualified Data.List as List (concat, foldl, foldr, head, 49 | # > init, last, length) 50 | # 51 | # - new_line: Import list starts always on new line. 52 | # 53 | # > import qualified Data.List as List 54 | # > (concat, foldl, foldr, head, init, last, length) 55 | # 56 | # Default: after alias 57 | list_align: after_alias 58 | 59 | # Long list align style takes effect when import is too long. This is 60 | # determined by 'columns' setting. 61 | # 62 | # - inline: This option will put as much specs on same line as possible. 63 | # 64 | # - new_line: Import list will start on new line. 65 | # 66 | # - new_line_multiline: Import list will start on new line when it's 67 | # short enough to fit to single line. Otherwise it'll be multiline. 68 | # 69 | # - multiline: One line per import list entry. 70 | # Type with contructor list acts like single import. 71 | # 72 | # > import qualified Data.Map as M 73 | # > ( empty 74 | # > , singleton 75 | # > , ... 76 | # > , delete 77 | # > ) 78 | # 79 | # Default: inline 80 | long_list_align: inline 81 | 82 | # List padding determines indentation of import list on lines after import. 83 | # This option affects 'list_align' and 'long_list_align'. 84 | list_padding: 2 85 | 86 | # Separate lists option affects formating of import list for type 87 | # or class. The only difference is single space between type and list 88 | # of constructors, selectors and class functions. 89 | # 90 | # - true: There is single space between Foldable type and list of it's 91 | # functions. 92 | # 93 | # > import Data.Foldable (Foldable (fold, foldl, foldMap)) 94 | # 95 | # - false: There is no space between Foldable type and list of it's 96 | # functions. 97 | # 98 | # > import Data.Foldable (Foldable(fold, foldl, foldMap)) 99 | # 100 | # Default: true 101 | separate_lists: true 102 | 103 | # Language pragmas 104 | - language_pragmas: 105 | # We can generate different styles of language pragma lists. 106 | # 107 | # - vertical: Vertical-spaced language pragmas, one per line. 108 | # 109 | # - compact: A more compact style. 110 | # 111 | # - compact_line: Similar to compact, but wrap each line with 112 | # `{-#LANGUAGE #-}'. 113 | # 114 | # Default: vertical. 115 | style: vertical 116 | 117 | # Align affects alignment of closing pragma brackets. 118 | # 119 | # - true: Brackets are aligned in same collumn. 120 | # 121 | # - false: Brackets are not aligned together. There is only one space 122 | # between actual import and closing bracket. 123 | # 124 | # Default: true 125 | align: false 126 | 127 | # stylish-haskell can detect redundancy of some language pragmas. If this 128 | # is set to true, it will remove those redundant pragmas. Default: true. 129 | remove_redundant: true 130 | 131 | # Align the types in record declarations 132 | # - records: {} 133 | 134 | # Replace tabs by spaces. This is disabled by default. 135 | # - tabs: 136 | # # Number of spaces to use for each tab. Default: 8, as specified by the 137 | # # Haskell report. 138 | # spaces: 8 139 | 140 | # Remove trailing whitespace 141 | - trailing_whitespace: {} 142 | 143 | # A common setting is the number of columns (parts of) code will be wrapped 144 | # to. Different steps take this into account. Default: 80. 145 | columns: 110 146 | 147 | # Sometimes, language extensions are specified in a cabal file or from the 148 | # command line instead of using language pragmas in the file. stylish-haskell 149 | # needs to be aware of these, so it can parse the file correctly. 150 | # 151 | # No language extensions are enabled by default. 152 | # language_extensions: 153 | # - TemplateHaskell 154 | # - QuasiQuotes 155 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Contributions and issue reports are encouraged and appreciated! 4 | 5 | - [Opening Issues](#opening-issues) 6 | - [Submitting Changes](#submitting-changes) 7 | - [Guidelines for Commit Messages](#guidelines-for-commit-messages) 8 | - [Guidelines for Pull Requests](#guidelines-for-pull-requests) 9 | - [Code Quality](#code-quality) 10 | - [Documentation](#documentation) 11 | 12 | ## Opening Issues 13 | 14 | Before opening an issue, please check whether your issue has already been reported. Assuming it has not: 15 | 16 | * Describe the issue you're encountering or the suggestion you're making 17 | * Include any relevant steps to reproduce or code samples you can. It's always easier for us to debug if we have something that demonstrates the error. 18 | * Let us know what version of this project you were using. If you're using a github checkout, provide the git hash. 19 | 20 | ## Submitting Changes 21 | 22 | Most pull requests should target the `develop` branch. `master` is the release branch. `develop` is periodically merged into master after a period of testing. 23 | 24 | ### Guidelines for Commit Messages 25 | 26 | #### Summary Line 27 | The summary line of your commit message should summarize the changes being made. Commit messages should be written in the imperative mood and should describe what happens when the commit is applied. If your commit modifies one of the in-tree haskell packages specifically, please prefix your commit summary with the name of the package being modified. 28 | 29 | One way to think about it is that your commit message should be able to complete the sentence: 30 | "When applied, this commit will..." 31 | 32 | ##### Note on bumping dependencies 33 | Commits that update a dependency should include some information about why the dependency was updated in the commit message. 34 | 35 | #### Body 36 | For breaking changes, new features, refactors, or other major changes, the body of the commit message should describe the motivation behind the change in greater detail and may include references to the issue tracker. The body shouldn't repeat code/comments from the diff. 37 | 38 | ### Guidelines for Pull Requests 39 | 40 | Wherever possible, pull requests should add a single feature or fix a single bug. Pull requests should not bundle several unrelated changes. 41 | 42 | ### Code Quality 43 | 44 | #### Warnings 45 | 46 | Your pull request should add no new warnings to the project. It should also generally not disable any warnings. 47 | 48 | #### Build and Test 49 | 50 | Make sure the project builds and that the tests pass! This will generally also be checked by CI before merge, but trying it yourself first means you'll catch problems earlier and your contribution can be merged that much sooner! 51 | 52 | You can run the tests like this: 53 | ```bash 54 | nix-build release.nix 55 | ``` 56 | 57 | ### Documentation 58 | 59 | #### In the code 60 | We're always striving to improve documentation. Please include [haddock](https://haskell-haddock.readthedocs.io/en/latest/index.html) documentation for any added code, and update the documentation for any code you modify. 61 | 62 | #### In the [Changelog](ChangeLog.md) 63 | Add an entry to the changelog when your PR: 64 | * Adds a feature 65 | * Deprecates something 66 | * Includes a breaking change 67 | * Makes any other change that will impact users 68 | 69 | #### In the [Readme](README.md) 70 | The readme is the first place a lot of people look for information about the repository. Update any parts of the readme that are affected by your PR. 71 | 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Obsidian Systems LLC 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rhyolite 2 | [![Obelisk](https://img.shields.io/badge/Powered%20By-Obelisk-black?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI3NjgiIGhlaWdodD0iNzY4Ij48ZyBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0zMDUuODggNjIyLjY3M2MtMzcuOTI0LTEyLjM4Ni03MS44NzMtMzMuNTU2LTk5LjQzNS02MS4xMThDMTYxLjAyIDUxNi4xMjkgMTMyLjk1MiA0NTMuMzQ0IDEzMi45NTIgMzg0YzAtNjkuMjU3IDI4LjA2Ny0xMzIuMTMgNzMuNDkzLTE3Ny41NTVDMjUxLjg3MSAxNjEuMDIgMzE0LjY1NiAxMzIuOTUyIDM4NCAxMzIuOTUyYzY5LjM0NCAwIDEzMi4xMyAyOC4wNjcgMTc3LjU1NSA3My40OTNDNjA2Ljk4IDI1MS44NzEgNjM1LjA0OCAzMTQuNzQzIDYzNS4wNDggMzg0YzAgNjkuMzQ0LTI4LjA2NyAxMzIuMTMtNzMuNDkzIDE3Ny41NTVDNTE2LjEyOSA2MDYuOTggNDUzLjM0NCA2MzUuMDQ4IDM4NCA2MzUuMDQ4VjE2MS4zNWwtMzkuNjEgMzIuMDU2LTM4LjUxIDQyOS4yNjYiIGZpbGw9IiMyZDMyM2IiLz48cGF0aCBkPSJNMzg0IDYwNi42NDdjNjEuNDk5IDAgMTE3LjE3OS0yNC44OTUgMTU3LjQ2NS02NS4xODJDNTgxLjc1MiA1MDEuMTggNjA2LjY0NyA0NDUuNSA2MDYuNjQ3IDM4NGMwLTYxLjQyNS0yNC44OTUtMTE3LjE3OS02NS4xODItMTU3LjQ2NUM1MDEuMTggMTg2LjI0OCA0NDUuNSAxNjEuMzUzIDM4NCAxNjEuMzUzdjQ0NS4yOTQiIGZpbGw9IiM3MDllYjUiLz48cGF0aCBkPSJNMzg0IDYzNS4wNDhjMjYuOTkgMCA1My41NjQtNC4yMzYgNzkuMjI1LTEyLjc5TDQyMy42MTMgMTkzLjQxIDM4NCAxNjEuMzUzdjQ3My42OTUiIGZpbGw9IiMyZDMyM2IiLz48L2c+PC9zdmc+)](https://github.com/obsidiansystems/obelisk) [![Haskell](https://img.shields.io/badge/language-Haskell-orange.svg)](https://haskell.org) [![Built with Nix](https://img.shields.io/static/v1?logo=nixos&logoColor=white&label=&message=Built%20with%20Nix&color=41439a)](https://nixos.org) [![BSD3 License](https://img.shields.io/badge/license-BSD3-blue.svg)](https://github.com/obsidiansystems/rhyolite/blob/master/LICENSE) 3 | 4 | Rhyolite is a collection of packages used to jump-start web application 5 | development using Obelisk and reflex. 6 | 7 | Rhyolite provides: 8 | * a client/server communication framework for both request/response APIs and 9 | live-updating queries; and 10 | * a library of types and components commonly used when building a web 11 | application. 12 | 13 | ## Using Rhyolite with an Obelisk project 14 | 15 | 1. Within an already initialized Obelisk project, clone Rhyolite into 16 | the dep directory, if you haven’t already: 17 | 18 | ```bash 19 | git clone https://github.com/obsidiansystems/rhyolite dep/rhyolite 20 | ``` 21 | 22 | 1. Add Rhyolite’s haskellOverrides to default.nix so that your package 23 | can access them. This involves adding overrides to the arguments 24 | passed to Obelisk’s project function so that it imports Rhyolite’s 25 | haskellOverrides. You can base it off of this example: 26 | 27 | ``` 28 | { system ? builtins.currentSystem, obelisk ? import ./.obelisk/impl { 29 | inherit system; 30 | iosSdkVersion = "13.2"; 31 | 32 | # You must accept the Android Software Development Kit License Agreement at 33 | # https://developer.android.com/studio/terms in order to build Android apps. 34 | # Uncomment and set this to `true` to indicate your acceptance: 35 | # config.android_sdk.accept_license = false; 36 | 37 | # In order to use Let's Encrypt for HTTPS deployments you must accept 38 | # their terms of service at https://letsencrypt.org/repository/. 39 | # Uncomment and set this to `true` to indicate your acceptance: 40 | # terms.security.acme.acceptTerms = false; 41 | } }: 42 | with obelisk; 43 | project ./. ({ pkgs, hackGet, ... }@args: { 44 | 45 | overrides = pkgs.lib.composeExtensions 46 | (pkgs.callPackage (hackGet ./dep/rhyolite) args).haskellOverrides 47 | (self: super: 48 | with pkgs.haskell.lib; 49 | { 50 | # Your custom overrides go here. 51 | }); 52 | 53 | android.applicationId = "systems.obsidian.obelisk.examples.minimal"; 54 | android.displayName = "Obelisk Minimal Example"; 55 | ios.bundleIdentifier = "systems.obsidian.obelisk.examples.minimal"; 56 | ios.bundleName = "Obelisk Minimal Example"; 57 | }) 58 | ``` 59 | 60 | 1. You can now add any of Rhyolite’s packages as dependencies to your 61 | Obelisk project. The packages Rhyolite provides are listed in [cabal.project](cabal.project). 62 | 63 | ### Alternative method 64 | 65 | You can also let Rhyolite manage Obelisk directly. This is easier to 66 | setup but also means that you are stuck with the Obelisk version used 67 | by Rhyolite. 68 | 69 | To do this, simply overwrite the ```.obelisk/impl/github.json``` file 70 | with this Rhyolite thunk: 71 | 72 | ``` 73 | { 74 | "owner": "obsidiansystems", 75 | "repo": "rhyolite", 76 | "branch": "master", 77 | "private": false, 78 | "rev": "9f13d8d8a2233aae54e15c39acf68181893b859a", 79 | "sha256": "1vhbw9bdqpfddavfjfdrq6kk5wwsd8hbgb8pnna9i2db3x3cmzvy" 80 | } 81 | ``` 82 | 83 | ## Hacking 84 | 85 | ### Entering a shell 86 | 87 | You can use `nix-shell -A proj.shells.ghc` to enter a shell from which you can build any of the subprojects in this repository. From that shell you can: 88 | 89 | * Enter a shell using `cabal repl /path/to/package` 90 | * Use ghcid by invoking `ghcid -c "cabal repl /path/to/package` 91 | * Build a package using `cabal build /path/to/package` 92 | * Generate haddock documentation using `cabal haddock /path/to/package` (e.g., `cabal haddock notify-listen/notify-listen-beam`) 93 | * Generate a standalone haddock page for all the packages in this repo by running `./gen-docs.sh` 94 | 95 | ### Testing that things build 96 | 97 | Because of the inter-related nature of these packages, `rhyolite-test-suite` tests that all of them can be built against one another. To test, run: 98 | 99 | ```bash 100 | nix-shell -A proj.shells.ghc --run "cabal build test" 101 | ``` 102 | -------------------------------------------------------------------------------- /account/backend/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /account/backend/rhyolite-account-backend.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-account-backend 3 | version: 0.1.0.0 4 | synopsis: Functions for creating and authenticating accounts 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: Rhyolite.Backend.Account 16 | Rhyolite.Backend.Account.Db 17 | Rhyolite.Backend.Account.Notify 18 | build-depends: base 19 | , aeson 20 | , beam-core 21 | , bytestring 22 | , text 23 | , time 24 | , beam-postgres 25 | , rhyolite-account-types 26 | , rhyolite-beam-db 27 | , constraints 28 | , constraints-extras 29 | , pwstore-fast 30 | , transformers 31 | , rhyolite-notify-listen 32 | , rhyolite-notify-listen-beam 33 | , psql-simple-beam 34 | , clientsession 35 | , signed-data 36 | , signed-data-clientsession 37 | hs-source-dirs: src 38 | default-language: Haskell2010 39 | -------------------------------------------------------------------------------- /account/backend/src/Rhyolite/Backend/Account.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Check or modify credentials 4 | -} 5 | {-# Language DeriveGeneric #-} 6 | {-# Language FlexibleContexts #-} 7 | {-# Language MonoLocalBinds #-} 8 | {-# Language OverloadedStrings #-} 9 | module Rhyolite.Backend.Account 10 | ( createAccount 11 | , ensureAccountExists 12 | , login 13 | , setAccountPassword 14 | , setAccountPasswordHash 15 | , makePasswordHash 16 | , passwordResetToken 17 | , newNonce 18 | , resetPassword 19 | , resetPasswordHash 20 | ) where 21 | 22 | import Rhyolite.Backend.Account.Db hiding (createAccount, ensureAccountExists) 23 | import Rhyolite.Backend.Account.Notify (createAccount, ensureAccountExists) 24 | -------------------------------------------------------------------------------- /account/backend/src/Rhyolite/Backend/Account/Db.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Create or modify accounts in the database 4 | 5 | This module does not handle notifications. See 6 | Rhyolite.Backend.Account.Notify for that 7 | -} 8 | {-# Language DeriveGeneric #-} 9 | {-# Language FlexibleContexts #-} 10 | {-# Language MonoLocalBinds #-} 11 | {-# Language OverloadedStrings #-} 12 | module Rhyolite.Backend.Account.Db 13 | ( createAccount 14 | , login 15 | , ensureAccountExists 16 | , setAccountPassword 17 | , setAccountPasswordHash 18 | , makePasswordHash 19 | , passwordResetToken 20 | , newNonce 21 | , resetPassword 22 | , resetPasswordHash 23 | ) where 24 | 25 | import Control.Monad (guard) 26 | import Control.Monad.Trans.Maybe 27 | import Crypto.PasswordStore 28 | import Data.ByteString 29 | import Data.Functor.Identity 30 | import Data.Maybe 31 | import Data.Signed 32 | import Data.Signed.ClientSession 33 | import Data.Text 34 | import Data.Text.Encoding (encodeUtf8) 35 | import qualified Data.Text.Encoding as T 36 | import Data.Time 37 | import Database.Beam 38 | import Database.Beam.Backend.SQL.BeamExtensions 39 | import Database.Beam.Postgres 40 | import Database.Beam.Postgres.Full hiding (insert) 41 | import Database.PostgreSQL.Simple.Beam () 42 | import Rhyolite.Account 43 | import Rhyolite.DB.Beam (current_timestamp_) 44 | import Web.ClientSession as CS 45 | 46 | -- | Creates a new account and emits a db notification about it 47 | createAccount 48 | :: DatabaseEntity Postgres db (TableEntity Account) 49 | -> Text 50 | -> Text 51 | -> Pg (Either Text (PrimaryKey Account Identity)) 52 | createAccount accountTable email pass = do 53 | hash <- makePasswordHash pass 54 | accountIds <- runPgInsertReturningList $ flip returning _account_id $ insert accountTable $ insertExpressions 55 | [ Account 56 | { _account_id = default_ 57 | , _account_email = lower_ (val_ email) 58 | , _account_password = val_ (Just hash) 59 | , _account_passwordResetNonce = just_ current_timestamp_ 60 | } 61 | ] 62 | case accountIds of 63 | [accountId] -> pure $ Right $ AccountId accountId 64 | _ -> pure $ Left "Failed to create account" 65 | 66 | -- | Attempts to login a user given some credentials. 67 | login 68 | :: Database Postgres db 69 | => DatabaseEntity Postgres db (TableEntity Account) 70 | -> Text 71 | -> Text 72 | -> Pg (Maybe (PrimaryKey Account Identity)) 73 | login accountTable email pass = runMaybeT $ do 74 | (aid, mPwHash) <- MaybeT $ fmap listToMaybe $ runSelectReturningList $ select $ do 75 | acc <- all_ accountTable 76 | guard_ $ lower_ (_account_email acc) ==. lower_ (val_ email) 77 | pure (_account_id acc, _account_password acc) 78 | pwHash <- MaybeT $ pure mPwHash 79 | guard $ verifyPasswordWith pbkdf2 (2^) (T.encodeUtf8 pass) pwHash 80 | pure (AccountId aid) 81 | 82 | ensureAccountExists 83 | :: (Database Postgres db) 84 | => DatabaseEntity Postgres db (TableEntity Account) 85 | -> Text 86 | -> Pg (Bool, PrimaryKey Account Identity) 87 | ensureAccountExists accountTable email = do 88 | existingAccountId <- runSelectReturningOne $ select $ fmap primaryKey $ filter_ (\x -> 89 | lower_ (_account_email x) ==. lower_ (val_ email)) $ all_ accountTable 90 | case existingAccountId of 91 | Just existing -> return (False, existing) 92 | Nothing -> do 93 | results <- runInsertReturningList $ insert accountTable $ insertExpressions 94 | [ Account 95 | { _account_id = default_ 96 | , _account_email = lower_ (val_ email) 97 | , _account_password = nothing_ 98 | , _account_passwordResetNonce = nothing_ 99 | } 100 | ] 101 | case results of 102 | [acc] -> do 103 | let aid = primaryKey acc 104 | pure (True, aid) 105 | _ -> error "ensureAccountExists: Creating account failed" 106 | 107 | setAccountPassword 108 | :: DatabaseEntity Postgres db (TableEntity Account) 109 | -> PrimaryKey Account Identity 110 | -> Text 111 | -> Pg () 112 | setAccountPassword tbl aid password = do 113 | pw <- liftIO $ makePasswordHash password 114 | setAccountPasswordHash tbl aid pw 115 | 116 | setAccountPasswordHash 117 | :: DatabaseEntity Postgres db (TableEntity Account) 118 | -> PrimaryKey Account Identity 119 | -> ByteString 120 | -> Pg () 121 | setAccountPasswordHash accountTable aid hash = runUpdate $ update accountTable 122 | (\x -> mconcat 123 | [ _account_password x <-. val_ (Just hash) 124 | , _account_passwordResetNonce x <-. nothing_ 125 | ] 126 | ) 127 | (\x -> primaryKey x ==. val_ aid) 128 | 129 | makePasswordHash 130 | :: MonadIO m 131 | => Text 132 | -> m ByteString 133 | makePasswordHash pw = do 134 | salt <- liftIO genSaltIO 135 | return $ makePasswordSaltWith pbkdf2 (2^) (encodeUtf8 pw) salt 19 136 | 137 | resetPassword 138 | :: (Database Postgres db) 139 | => DatabaseEntity Postgres db (TableEntity Account) 140 | -> PrimaryKey Account Identity 141 | -> UTCTime 142 | -> Text 143 | -> Pg (Maybe (PrimaryKey Account Identity)) 144 | resetPassword tbl aid t pw = do 145 | hash <- makePasswordHash pw 146 | resetPasswordHash tbl aid t hash 147 | 148 | resetPasswordHash 149 | :: (Database Postgres db) 150 | => DatabaseEntity Postgres db (TableEntity Account) 151 | -> PrimaryKey Account Identity 152 | -> UTCTime 153 | -> ByteString 154 | -> Pg (Maybe (PrimaryKey Account Identity)) 155 | resetPasswordHash accountTable aid nonce pwhash = do 156 | macc <- runSelectReturningOne $ lookup_ accountTable aid 157 | case macc of 158 | Nothing -> return Nothing 159 | Just a -> if _account_passwordResetNonce a == Just nonce 160 | then do 161 | setAccountPasswordHash accountTable aid pwhash 162 | return $ Just aid 163 | else fail "nonce mismatch" 164 | 165 | passwordResetToken 166 | :: MonadIO m 167 | => CS.Key 168 | -> PrimaryKey Account Identity 169 | -> UTCTime 170 | -> m (Signed PasswordResetToken) 171 | passwordResetToken csk aid nonce = do 172 | liftIO $ signWithKey csk $ PasswordResetToken (aid, nonce) 173 | 174 | newNonce 175 | :: DatabaseEntity Postgres db (TableEntity Account) 176 | -> PrimaryKey Account Identity 177 | -> Pg (Maybe UTCTime) 178 | newNonce accountTable aid = do 179 | a <- runUpdateReturningList $ update accountTable 180 | (\x -> _account_passwordResetNonce x <-. just_ current_timestamp_) 181 | (\x -> primaryKey x ==. val_ aid) 182 | pure $ case a of 183 | [acc] -> _account_passwordResetNonce acc 184 | _ -> Nothing 185 | -------------------------------------------------------------------------------- /account/backend/src/Rhyolite/Backend/Account/Notify.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Create or modify accounts in the database, and send LISTEN notifications 4 | -} 5 | {-# Language DeriveGeneric #-} 6 | {-# Language FlexibleContexts #-} 7 | {-# Language MonoLocalBinds #-} 8 | {-# Language OverloadedStrings #-} 9 | module Rhyolite.Backend.Account.Notify 10 | ( createAccount 11 | , login 12 | , ensureAccountExists 13 | , setAccountPassword 14 | , setAccountPasswordHash 15 | , makePasswordHash 16 | , passwordResetToken 17 | , newNonce 18 | , resetPassword 19 | , resetPasswordHash 20 | ) where 21 | 22 | import Data.Aeson 23 | import Data.Constraint.Extras 24 | import Data.Constraint.Forall 25 | import Data.Functor.Identity 26 | import Data.Text 27 | import Database.Beam 28 | import Database.Beam.Postgres 29 | import Database.PostgreSQL.Simple.Beam () 30 | import Rhyolite.Account 31 | import Rhyolite.Backend.Account.Db hiding (createAccount, ensureAccountExists) 32 | import qualified Rhyolite.Backend.Account.Db as Acc 33 | import Rhyolite.DB.NotifyListen 34 | import Rhyolite.DB.NotifyListen.Beam 35 | 36 | -- | Creates a new account and emits a db notification about it 37 | createAccount 38 | :: (Has' ToJSON notice Identity, ForallF ToJSON notice) 39 | => DatabaseEntity Postgres db (TableEntity Account) 40 | -> notice (PrimaryKey Account Identity) 41 | -> Text 42 | -> Text 43 | -> Pg (Either Text (PrimaryKey Account Identity)) 44 | createAccount accountTable noticeWrapper email pass = do 45 | result <- Acc.createAccount accountTable email pass 46 | case result of 47 | Right accountId -> 48 | notify NotificationType_Insert noticeWrapper accountId 49 | _ -> pure () 50 | pure result 51 | 52 | ensureAccountExists 53 | :: (Database Postgres db, HasNotification n Account, Has' ToJSON n Identity, ForallF ToJSON n) 54 | => DatabaseEntity Postgres db (TableEntity Account) 55 | -> Text 56 | -> Pg (Bool, PrimaryKey Account Identity) 57 | ensureAccountExists accountTable email = do 58 | aid <- Acc.ensureAccountExists accountTable email 59 | notify NotificationType_Insert (notification accountTable) $ snd aid 60 | pure aid 61 | -------------------------------------------------------------------------------- /account/types/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /account/types/rhyolite-account-types.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-account-types 3 | version: 0.1.0.0 4 | synopsis: Account types for rhyolite apps 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: Rhyolite.Account 16 | build-depends: base 17 | , aeson 18 | , beam-core 19 | , bytestring 20 | , text 21 | , time 22 | hs-source-dirs: src 23 | default-language: Haskell2010 24 | -------------------------------------------------------------------------------- /account/types/src/Rhyolite/Account.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Default email-based account type 4 | -} 5 | {-# Language DeriveGeneric #-} 6 | {-# Language FlexibleInstances #-} 7 | {-# Language StandaloneDeriving #-} 8 | {-# Language TypeFamilies #-} 9 | module Rhyolite.Account where 10 | 11 | import Data.Aeson 12 | import Data.ByteString (ByteString) 13 | import Data.Functor.Identity 14 | import Data.Int (Int64) 15 | import Data.Text (Text) 16 | import Data.Time (UTCTime) 17 | import Database.Beam.Backend.SQL.Types 18 | import Database.Beam.Schema 19 | import GHC.Generics 20 | 21 | -- | The Account table defines user identities and how to authenticate them 22 | data Account f = Account 23 | { _account_id :: Columnar f (SqlSerial Int64) 24 | , _account_email :: Columnar f Text 25 | , _account_password :: Columnar f (Maybe ByteString) 26 | , _account_passwordResetNonce :: Columnar f (Maybe UTCTime) 27 | } deriving (Generic) 28 | 29 | instance Beamable Account 30 | 31 | instance Table Account where 32 | newtype PrimaryKey Account f = AccountId 33 | { _accountId_id :: Columnar f (SqlSerial Int64) 34 | } 35 | deriving (Generic) 36 | primaryKey = AccountId . _account_id 37 | 38 | instance Beamable (PrimaryKey Account) 39 | 40 | type AccountId = PrimaryKey Account Identity 41 | 42 | deriving instance Eq (PrimaryKey Account Identity) 43 | deriving instance Ord (PrimaryKey Account Identity) 44 | deriving instance Show (PrimaryKey Account Identity) 45 | 46 | instance ToJSON (PrimaryKey Account Identity) 47 | instance FromJSON (PrimaryKey Account Identity) 48 | instance ToJSONKey (PrimaryKey Account Identity) 49 | instance FromJSONKey (PrimaryKey Account Identity) 50 | 51 | newtype PasswordResetToken = PasswordResetToken 52 | { unPasswordResetToken :: (PrimaryKey Account Identity, UTCTime) 53 | } 54 | deriving (Generic) 55 | 56 | instance ToJSON PasswordResetToken 57 | instance FromJSON PasswordResetToken 58 | -------------------------------------------------------------------------------- /backend/Rhyolite/Backend/WebSocket.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Websocket connection, send, and receive 3 | 4 | Setting up the WebSocket connection using 'Snap.Core' and primitives for 5 | getting and setting websocket messages. 6 | -} 7 | module Rhyolite.Backend.WebSocket where 8 | 9 | import Control.Exception (AssertionFailed(..), SomeException(..), handle, throwIO) 10 | import Control.Monad.IO.Class (liftIO) 11 | import Data.Aeson (FromJSON, ToJSON, eitherDecode', encode) 12 | import Data.Semigroup ((<>)) 13 | import qualified Network.WebSockets as WS 14 | import qualified Network.WebSockets.Connection as WS 15 | import Network.WebSockets.Snap (runWebSocketsSnap) 16 | import qualified Network.WebSockets.Stream as WS 17 | import Snap.Core (MonadSnap) 18 | 19 | -- | Accepts a websockets connection and runs the supplied action with it using the given logging function 20 | -- when an error occurs. 21 | withWebsocketsConnectionLogging :: MonadSnap m => (String -> SomeException -> IO ()) -> (WS.Connection -> IO ()) -> m () 22 | withWebsocketsConnectionLogging logger f = runWebSocketsSnap $ withPendingWebsocketConnection logger f 23 | 24 | -- | Like 'withWebsocketsConnectionLogging' but with a default logging function using 'putStrLn'. 25 | withWebsocketsConnection :: MonadSnap m => (WS.Connection -> IO ()) -> m () 26 | withWebsocketsConnection f = runWebSocketsSnap $ withPendingWebsocketConnection logger f 27 | where 28 | logger str e = putStrLn $ "withWebsocketsConnection: " <> (if null str then "" else str <> ": ") <> show e 29 | 30 | -- | Handle a websocket connection that is in a "pending" state (i.e., that hasn't yet been accepted) 31 | withPendingWebsocketConnection :: (String -> SomeException -> IO ()) -> (WS.Connection -> IO ()) -> WS.PendingConnection -> IO () 32 | withPendingWebsocketConnection logger f pc = do 33 | conn <- WS.acceptRequest pc 34 | handleSomeException $ handleConnectionException $ f conn 35 | where 36 | handleSomeException = handle $ \e -> logger "" e 37 | handleConnectionException = handle $ \e -> case e of 38 | WS.ConnectionClosed -> return () 39 | WS.CloseRequest _ _ -> print e >> WS.close (WS.pendingStream pc) >> throwIO e 40 | _ -> logger "Exception" (SomeException e) *> throwIO e 41 | 42 | 43 | -- | Attempts to json decode a websockets data message 44 | decodeWebsocketsDataMessage :: FromJSON a => WS.DataMessage -> Either String a 45 | decodeWebsocketsDataMessage dm = eitherDecode' $ case dm of 46 | WS.Text r' _ -> r' 47 | WS.Binary r' -> r' 48 | 49 | -- | Parse and process a single websocket data message 50 | getDataMessage 51 | :: FromJSON a 52 | => WS.Connection 53 | -> IO a 54 | getDataMessage conn = do 55 | dm <- WS.receiveDataMessage conn 56 | case decodeWebsocketsDataMessage dm of 57 | Left err -> liftIO $ throwIO $ AssertionFailed $ mconcat 58 | [ "getDataMessage: error: " 59 | , err 60 | , "; received: " 61 | , show dm 62 | ] 63 | Right a -> return a 64 | 65 | -- | Send a json encoded data message over the websocket connection 66 | sendEncodedDataMessage 67 | :: ToJSON a 68 | => WS.Connection 69 | -> a 70 | -> IO () 71 | sendEncodedDataMessage conn = WS.sendDataMessage conn . (\x -> WS.Text x Nothing) . encode 72 | -------------------------------------------------------------------------------- /backend/rhyolite-backend.cabal: -------------------------------------------------------------------------------- 1 | name: rhyolite-backend 2 | version: 0.1 3 | license: BSD3 4 | copyright: Copyright (C) Obsidian Systems LLC 2018 5 | author: Obsidian Systems LLC 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | build-type: Simple 9 | cabal-version: >=1.2 10 | synopsis: Backend infrastructure to support FRP-based frontends 11 | description: 12 | Rhyolite backends are designed to deliver live-updating data to reflex-powered applications. 13 | 14 | library 15 | hs-source-dirs: . 16 | build-depends: 17 | aeson 18 | , base 19 | , commutative-semigroups 20 | , constraints 21 | , constraints-extras 22 | , lens 23 | , monoid-map 24 | , monoidal-containers 25 | , patch 26 | , postgresql-simple 27 | , reflex 28 | , resource-pool 29 | , rhyolite-common 30 | , rhyolite-notify-listen 31 | , semialign 32 | , signed-data 33 | , signed-data-clientsession 34 | , snap-core 35 | , some 36 | , text 37 | , vessel 38 | , websockets 39 | , websockets-snap 40 | , witherable 41 | 42 | exposed-modules: 43 | Rhyolite.Backend.App 44 | Rhyolite.Backend.WebSocket 45 | 46 | other-extensions: TemplateHaskell 47 | ghc-options: 48 | -Wall -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields 49 | -O2 -fprof-auto-calls 50 | -------------------------------------------------------------------------------- /beam/db/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /beam/db/rhyolite-beam-db.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-beam-db 3 | version: 0.1.0.0 4 | synopsis: Beam database functions. 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: 16 | Rhyolite.DB.Beam 17 | Rhyolite.DB.Beam.Types 18 | 19 | build-depends: 20 | base 21 | , lens 22 | , rhyolite-beam-orphans 23 | , beam-core 24 | , beam-postgres 25 | , postgresql-simple 26 | , time 27 | 28 | hs-source-dirs: src 29 | default-language: Haskell2010 30 | ghc-options: -Wall 31 | -------------------------------------------------------------------------------- /beam/db/src/Rhyolite/DB/Beam.hs: -------------------------------------------------------------------------------- 1 | {-# Language FlexibleContexts #-} 2 | {-# Language OverloadedStrings #-} 3 | {-# Language TypeFamilies #-} 4 | {-| 5 | Description : Utility functions for using Beam with Postgres Transactions. 6 | -} 7 | module Rhyolite.DB.Beam where 8 | 9 | import Data.Time 10 | import Database.Beam 11 | import Database.Beam.Postgres 12 | import Database.Beam.Postgres.Syntax 13 | import Database.PostgreSQL.Simple.Transaction 14 | 15 | import Rhyolite.DB.Beam.Orphans () 16 | 17 | -- | Run beam SQL statements inside a Postgres Serializable Transaction 18 | withTransactionSerializableRunBeamPostgres :: (MonadIO m) => Connection -> Pg a -> m a 19 | withTransactionSerializableRunBeamPostgres dbConn = liftIO . withTransactionSerializable dbConn . runBeamPostgres dbConn 20 | 21 | -- | Postgres @current_timestamp()@ function. Returns the server's timestamp 22 | current_timestamp_ :: QExpr Postgres s UTCTime 23 | -- Note: Previously we used the SQL expression @current_timestamp at time zone 'UTC'@ 24 | -- here instead of @current_timestamp@. 25 | -- This caused a bug because this expression returns the current UTC time 26 | -- but without any time zone information; ie. the value would have the type 27 | -- @TIMESTAMP WITHOUT TIME ZONE@. When a value of this type is inserted 28 | -- into a column of type @TIMESTAMP WITH TIME ZONE@ it is cast into a value 29 | -- of the latter type by assuming the time zone is whatever the Postgres 30 | -- server is configured to use. This caused wrong timestamps to be inserted 31 | -- for all Postgres server instances whose time zone was not configured to 32 | -- be UTC. 33 | -- The type of the expression @current_timestamp@ is @TIMESTAMP WITH TIME ZONE@, 34 | -- which Postgres internally converts into a UTC timestamp which it stores. 35 | -- When this is retrieved from the database, Postgres read the stored UTC 36 | -- timestamp and returns the timestamp string using whatever time zone the 37 | -- server is configured to use. This string is deserialized into a 'UTCTime' 38 | -- (by 'postgresql-simple') by looking at the returned time zone offset and 39 | -- adjusting to UTC. 40 | current_timestamp_ = QExpr (\_ -> PgExpressionSyntax (emit "current_timestamp")) 41 | -------------------------------------------------------------------------------- /beam/db/src/Rhyolite/DB/Beam/Types.hs: -------------------------------------------------------------------------------- 1 | {-# language DeriveGeneric #-} 2 | {-# language FlexibleInstances #-} 3 | {-# language MultiParamTypeClasses #-} 4 | {-# language PolyKinds #-} 5 | {-# language RankNTypes #-} 6 | {-# language TemplateHaskell #-} 7 | {-# language TypeFamilies #-} 8 | {-| 9 | Description: 10 | Types and helpers for Beam that can be used in schemas and database interactions. 11 | -} 12 | module Rhyolite.DB.Beam.Types where 13 | 14 | import Control.Lens 15 | import Data.Proxy 16 | import Database.Beam 17 | import Rhyolite.DB.Beam.Orphans () 18 | 19 | -- | Like "Columnar", but a newtype instead of a type family, and the arguments 20 | -- are in a more convenient order. 21 | newtype WrapColumnar a f = WrapColumnar { unWrapColumnar :: Columnar f a } 22 | deriving (Generic) 23 | 24 | instance Beamable (WrapColumnar a) 25 | 26 | makeWrapped ''WrapColumnar 27 | 28 | -- | Used to decorate a beam thing like PrimaryKey with Nullable 29 | newtype WrapNullable k f = WrapNullable { unWrapNullable :: k (Nullable f) } 30 | deriving (Generic) 31 | 32 | makeWrapped ''WrapNullable 33 | 34 | -- | Useful in 'Rhyolite.Task.Beam.Task' for "no data" in payload or result. 35 | -- Every table has a subtable of type 'DummyTable'. 36 | type DummyTable = (Proxy :: k -> *) 37 | 38 | -- | Nicer name for 'Proxy' that explains what it's useful for in certain 39 | -- contexts: A phantom standing in for a null part of a schema. 40 | dummyTable :: forall k (x :: k). Proxy x 41 | dummyTable = Proxy 42 | 43 | -- | Every Beam table has a subtable of 'DummyTable'. 44 | dummyTableLens :: forall k x. Lens' (k x) (DummyTable x) 45 | dummyTableLens = lens 46 | (\_ -> Proxy) 47 | (\t _ -> t) 48 | -------------------------------------------------------------------------------- /beam/orphans/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /beam/orphans/rhyolite-beam-orphans.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-beam-orphans 3 | version: 0.1.0.0 4 | synopsis: Missing instances that have not yet been upstreamed to beam 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: 16 | Rhyolite.DB.Beam.Orphans 17 | 18 | build-depends: 19 | base 20 | , beam-core 21 | 22 | hs-source-dirs: src 23 | default-language: Haskell2010 24 | ghc-options: -Wall 25 | -------------------------------------------------------------------------------- /beam/orphans/src/Rhyolite/DB/Beam/Orphans.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -fno-warn-orphans #-} 2 | {-# language TypeOperators #-} 3 | {-| 4 | Description: 5 | Types and helpers for Beam that can be used in schemas and database interactions. 6 | -} 7 | module Rhyolite.DB.Beam.Orphans where 8 | 9 | import Data.Functor.Product 10 | import Data.Proxy 11 | import Database.Beam 12 | import GHC.Generics 13 | 14 | instance Beamable Proxy 15 | instance (Beamable f, Beamable g) => Beamable (f :*: g) 16 | instance (Beamable f, Beamable g) => Beamable (Product f g) 17 | -------------------------------------------------------------------------------- /beam/task/backend/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /beam/task/backend/rhyolite-beam-task-worker-backend.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-beam-task-worker-backend 3 | version: 0.1.0.0 4 | synopsis: Functions for beam based task workers. 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: 16 | Rhyolite.Task.Beam.Worker 17 | 18 | build-depends: 19 | base 20 | , async 21 | , beam-core 22 | , beam-postgres 23 | , lens 24 | , lifted-base 25 | , monad-logger 26 | , monad-logger-extras 27 | , monad-control 28 | , mtl 29 | , psql-serializable 30 | , rhyolite-beam-db 31 | , rhyolite-beam-task-worker-types 32 | , time 33 | , unbounded-delays 34 | 35 | hs-source-dirs: src 36 | default-language: Haskell2010 37 | ghc-options: -Wall 38 | 39 | test-suite test 40 | other-modules: 41 | Types 42 | , Utils 43 | 44 | type: exitcode-stdio-1.0 45 | main-is: Test.hs 46 | hs-source-dirs: test 47 | default-language: Haskell2010 48 | build-depends: 49 | rhyolite-beam-task-worker-backend 50 | , rhyolite-beam-task-worker-types 51 | , base 52 | , beam-automigrate 53 | , beam-core 54 | , beam-postgres 55 | , containers 56 | , filepath 57 | , gargoyle-postgresql-connect 58 | , hspec 59 | , lens 60 | , monad-logger 61 | , postgresql-simple 62 | , psql-serializable 63 | , resource-pool 64 | , text 65 | , unix 66 | ghc-options: -threaded 67 | -------------------------------------------------------------------------------- /beam/task/backend/src/Rhyolite/Task/Beam/Worker.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | {-# Language RankNTypes #-} 3 | {-# Language ScopedTypeVariables #-} 4 | {-# Language TypeFamilies #-} 5 | {-| 6 | Description : Utility functions for creating worker threads, and Beam specific task workers for running SQL. 7 | -} 8 | module Rhyolite.Task.Beam.Worker where 9 | 10 | import Control.Lens (view) 11 | import Control.Concurrent 12 | import Control.Concurrent.Async 13 | import Control.Concurrent.Thread.Delay 14 | import Control.Exception.Lifted (bracket) 15 | import Control.Monad (forM) 16 | import Control.Monad.Cont 17 | import Control.Monad.Trans.Control 18 | import Data.Time 19 | 20 | import Database.Beam 21 | import Database.Beam.Backend.SQL 22 | import Database.Beam.Postgres 23 | import Database.Beam.Postgres.Syntax 24 | import Database.Beam.Query.Internal (QNested) 25 | import Database.Beam.Schema.Tables 26 | 27 | import Rhyolite.DB.Beam 28 | import Rhyolite.Task.Beam 29 | import Rhyolite.DB.Beam.Types (WrapColumnar(..)) 30 | 31 | -- | Takes a worker continuation and handles checking out and checking in a task 32 | -- that is stored in a database table. The 'Rhyolite.Task.Beam.Task' type tells 33 | -- it how to find eligible tasks, how to extract a useful payload from the row, 34 | -- and how to put results back into the row while the continuation does the real 35 | -- work. 36 | -- 37 | -- The worker continuation is divided into 3 phases: 38 | -- 39 | -- 1. A checkout action that is transaction safe (it may retry). 40 | -- 2. A work action that is not transaction safe (it will not retry). 41 | -- 3. A commit action that is transaction safe (it may retry). 42 | -- 43 | -- The continuation can perform its own queries in the checkout transaction but 44 | -- it is ideal to spend as little time as possible in this phase for the sake 45 | -- of throughput. 46 | -- 47 | -- This function differs from $taskWorker$ in that there are no guardrails to 48 | -- prevent accidental (or intentional) task loops. unless the result changes 49 | -- the task to no longer match the filter, the task will run again. 50 | taskWorkerWithoutHasRun 51 | :: forall m be db table f payload checkout result. 52 | ( MonadIO m, Database be db, Beamable table, Table table 53 | , Beamable payload, Beamable result 54 | , FromBackendRow be (PrimaryKey table Identity) 55 | , FromBackendRow be (payload Identity) 56 | , FieldsFulfillConstraint (HasSqlEqualityCheck be) (PrimaryKey table) 57 | , FieldsFulfillConstraint (HasSqlValueSyntax PgValueSyntax) (PrimaryKey table) 58 | , FieldsFulfillConstraint (HasSqlValueSyntax PgValueSyntax) result 59 | , HasSqlValueSyntax PgValueSyntax checkout 60 | , be ~ Postgres, f ~ QExpr Postgres (QNested QBaseScope) 61 | ) 62 | => Connection 63 | -> DatabaseEntity be db (TableEntity table) 64 | -- ^ The table whose rows represent tasks to be run 65 | -> TaskWithoutHasRun be table payload checkout result 66 | -- ^ Description of how task data is embedded within the table 67 | -> (PrimaryKey table Identity -> payload Identity -> Pg (m (Pg (result Identity)))) 68 | -- ^ Worker continuation 69 | -> checkout 70 | -- ^ Identifier for the worker checking out the task 71 | -> m Bool 72 | taskWorkerWithoutHasRun dbConn table schema k checkoutId = do 73 | -- Checkout Phase 74 | mCheckout <- 75 | -- Do the following inside a transaction: 76 | -- 1. Get the first task that is not currently checked out by any worker 77 | -- 2. Update this task to reflect that it has been checked out by current worker 78 | -- 3. Run the specified checkout task which returns the work continuation 79 | withTransactionSerializableRunBeamPostgres dbConn $ do 80 | primaryKeyAndInput <- runSelectReturningOne $ select $ limit_ 1 $ do 81 | task <- all_ table 82 | 83 | -- Both task fields should be empty for an unclaimed task 84 | -- Also apply any other filters that may have been passed, using ready 85 | guard_ $ isNothing_ (_taskWithoutHasRun_checkedOutBy schema task) 86 | &&. (_taskWithoutHasRun_filter schema task) 87 | 88 | -- Return the primary key (task id) along with a custom field that the user asked for. 89 | pure (primaryKey task, _taskWithoutHasRun_payload schema task) 90 | -- In case we did not find any rows, no update SQL will be run 91 | -- The row lock that we acquired above will be reset when the transaction ends. 92 | forM primaryKeyAndInput $ \(taskId, input) -> do 93 | -- Mark the retrieved task as checked out, by the current worker 94 | runUpdate $ 95 | update table 96 | (\task -> _taskWithoutHasRun_checkedOutBy schema task <-. val_ (Just checkoutId)) 97 | (\task -> primaryKey task ==. val_ taskId) 98 | 99 | (,) taskId <$> k taskId input 100 | case mCheckout of 101 | Nothing -> pure False 102 | Just (taskId, workAction) -> do 103 | -- Work phase 104 | commitAction <- workAction 105 | 106 | -- Commit phase 107 | withTransactionSerializableRunBeamPostgres dbConn $ do 108 | -- Get the result value from the serializable 109 | b <- commitAction 110 | 111 | -- Update the task's result field, set checked out field to null 112 | runUpdate $ update table 113 | (\task -> mconcat 114 | [ _taskWithoutHasRun_result schema task <-. val_ b 115 | , _taskWithoutHasRun_checkedOutBy schema task <-. val_ Nothing 116 | ]) 117 | (\task -> primaryKey task ==. val_ taskId) 118 | 119 | pure True 120 | 121 | 122 | -- | Takes a worker continuation and handles checking out and checking in a task 123 | -- that is stored in a database table. The 'Rhyolite.Task.Beam.Task' type tells 124 | -- it how to find eligible tasks, how to extract a useful payload from the row, 125 | -- and how to put results back into the row while the continuation does the real 126 | -- work. 127 | -- 128 | -- The worker continuation is divided into 3 phases: 129 | -- 130 | -- 1. A checkout action that is transaction safe (it may retry). 131 | -- 2. A work action that is not transaction safe (it will not retry). 132 | -- 3. A commit action that is transaction safe (it may retry). 133 | -- 134 | -- The continuation can perform its own queries in the checkout transaction but 135 | -- it is ideal to spend as little time as possible in this phase for the sake 136 | -- of throughput. 137 | -- 138 | -- This function differs from $taskWorkerWithoutHasRun$ in that it enforces a 139 | -- task stop via $_task_hasRun$; which must be False to be selected for 140 | -- execution, and is unconditionally set to True after the task is completed; 141 | taskWorker 142 | :: forall m be db table f payload checkout result. 143 | ( MonadIO m, Database be db, Beamable table, Table table 144 | , Beamable payload, Beamable result 145 | , FromBackendRow be (PrimaryKey table Identity) 146 | , FromBackendRow be (payload Identity) 147 | , FieldsFulfillConstraint (HasSqlEqualityCheck be) (PrimaryKey table) 148 | , FieldsFulfillConstraint (HasSqlValueSyntax PgValueSyntax) (PrimaryKey table) 149 | , FieldsFulfillConstraint (HasSqlValueSyntax PgValueSyntax) result 150 | , HasSqlValueSyntax PgValueSyntax checkout 151 | , be ~ Postgres, f ~ QExpr Postgres (QNested QBaseScope) 152 | ) 153 | => Connection 154 | -> DatabaseEntity be db (TableEntity table) 155 | -- ^ The table whose rows represent tasks to be run 156 | -> Task be table payload checkout result 157 | -- ^ Description of how task data is embedded within the table 158 | -> (PrimaryKey table Identity -> payload Identity -> Pg (m (Pg (result Identity)))) 159 | -- ^ Worker continuation 160 | -> checkout 161 | -- ^ Identifier for the worker checking out the task 162 | -> m Bool 163 | taskWorker dbConn table schema k = taskWorkerWithoutHasRun dbConn table schema1 $ \tId p -> do 164 | k' <- k tId p 165 | pure $ do 166 | k'' <- k' 167 | pure $ do 168 | res <- k'' 169 | pure (res :*: WrapColumnar True) 170 | where 171 | schema1 = TaskWithoutHasRun 172 | { _taskWithoutHasRun_filter = \tbl -> not_ (view (_task_hasRun schema) tbl) &&. _task_filter schema tbl 173 | , _taskWithoutHasRun_payload = _task_payload schema 174 | , _taskWithoutHasRun_result = \tbl -> view (_task_result schema) tbl :*: WrapColumnar (view (_task_hasRun schema) tbl) 175 | , _taskWithoutHasRun_checkedOutBy = view (_task_checkedOutBy schema) 176 | } 177 | 178 | 179 | -- | Run a worker thread 180 | -- The worker will wake up whenever the timer expires or the wakeup action is called 181 | -- Once woken up, the worker will be run repeatedly until it reports that it was not able to find any work to do; then it will start sleeping for the given duration. 182 | -- If the wakeup action is called while the worker is running, the worker will run again as soon as it finishes, even if it returns False. This is necessary because otherwise, since checking for work isn't generally atomic, there would be a race condition: worker starts (e.g. enters looking-for-work transaction), work is created, wakeup called, worker finishes with no work found, sleep. 183 | withWorker 184 | :: (MonadIO m, MonadBaseControl IO m) 185 | => NominalDiffTime 186 | -- ^ Sleep interval 187 | -> IO Bool 188 | -- ^ The worker action, which reports whether it found work to do or not. 189 | -> (IO () -> m a) 190 | -- ^ Program that is given the option to prompt the worker thread to look for work. 191 | -> m a 192 | withWorker d work child = do 193 | initialStartVar <- liftIO $ newMVar () 194 | startVarVar <- liftIO $ newMVar initialStartVar 195 | let wakeup = void $ withMVar startVarVar $ \startVar -> tryPutMVar startVar () 196 | sleep startVar = void $ forkIO $ do 197 | delay $ ceiling $ d * 1000000 198 | putMVar startVar () -- Do this blockingly so the thread can theoretically be GCed if it becomes useless 199 | go startVar = do 200 | nextStartVar <- newEmptyMVar 201 | void $ takeMVar startVar 202 | modifyMVar_ startVarVar $ \_ -> pure nextStartVar 203 | didWork <- withAsync work waitCatch 204 | case didWork of 205 | Left e -> do 206 | putStrLn $ "withWorker: error in worker: " <> show e --TODO: Use MonadLogger 207 | sleep nextStartVar 208 | Right True -> void $ tryPutMVar nextStartVar () 209 | Right False -> sleep nextStartVar 210 | go nextStartVar 211 | bracket (liftIO $ async $ go initialStartVar) (liftIO . cancel) $ \_ -> child wakeup 212 | 213 | -- | Run multiple workers in parallel on the same task 214 | withConcurrentWorkers 215 | :: forall m r a. (MonadIO m, MonadBaseControl IO m) 216 | => Int 217 | -- ^ Number of workers 218 | -> NominalDiffTime 219 | -- ^ Sleep interval for each worker 220 | -> (a -> IO Bool) 221 | -- ^ Parameterized worker action 222 | -> (Int -> a) 223 | -- ^ How to derive the worker's action parameter from its index 224 | -> (IO () -> m r) 225 | -- ^ Program that is given the option to prompt all the worker threads to look for work. 226 | -> m r 227 | withConcurrentWorkers n0 d work argFn = runContT $ go n0 228 | where 229 | go :: Int -> ContT r m (IO ()) 230 | go n = do 231 | wakeup <- ContT $ withWorker d (work $ argFn n) 232 | wakeupRest <- if n > 1 233 | then go (n-1) 234 | else return $ return () 235 | return (wakeup *> wakeupRest) 236 | -------------------------------------------------------------------------------- /beam/task/backend/test/Types.hs: -------------------------------------------------------------------------------- 1 | {-# Language DataKinds #-} 2 | {-# Language DeriveAnyClass #-} 3 | {-# Language DeriveGeneric #-} 4 | {-# Language FlexibleInstances #-} 5 | {-# LANGUAGE MultiParamTypeClasses #-} 6 | {-# LANGUAGE OverloadedStrings #-} 7 | {-# Language StandaloneDeriving #-} 8 | {-# Language TemplateHaskell #-} 9 | {-# LANGUAGE TypeApplications #-} 10 | {-# Language TypeFamilies #-} 11 | 12 | module Types where 13 | 14 | import Control.Exception 15 | import Control.Lens 16 | import Control.Monad.Logger 17 | import Data.Int (Int64) 18 | import Data.Proxy 19 | import Data.String (fromString) 20 | import Data.Text (Text) 21 | import Database.Beam 22 | import qualified Database.Beam.AutoMigrate as BA 23 | import Database.Beam.Postgres 24 | 25 | import Rhyolite.Task.Beam 26 | 27 | data TestTaskT f = TestTaskT 28 | { _testTaskT_id :: Columnar f Int64 29 | , _testTaskT_checkedOutBy :: Columnar f (Maybe Text) 30 | , _testTaskT_payload :: Columnar f Int64 31 | , _testTaskT_result :: Columnar f (Maybe Bool) 32 | , _testTaskT_finished :: Columnar f Bool 33 | } deriving (Generic) 34 | 35 | makeLenses ''TestTaskT 36 | 37 | type TestTask = TestTaskT Identity 38 | type TestTaskId = PrimaryKey TestTaskT Identity 39 | 40 | deriving instance Eq TestTask 41 | 42 | instance Beamable TestTaskT 43 | instance Table TestTaskT where 44 | newtype PrimaryKey TestTaskT f = TestTaskId { unTestTaskId :: Columnar f Int64 } 45 | deriving (Generic) 46 | primaryKey = TestTaskId . _testTaskT_id 47 | 48 | instance Beamable (PrimaryKey TestTaskT) 49 | 50 | newtype WrappedColumnar a f = WrappedColumnar { unWrappedColumnar :: Columnar f a } 51 | deriving (Generic) 52 | 53 | instance Beamable (WrappedColumnar a) 54 | 55 | testTask :: Task Postgres TestTaskT (WrappedColumnar Int64) Text (WrappedColumnar (Maybe Bool)) 56 | testTask = Task 57 | { _task_filter = \_ -> val_ True 58 | , _task_payload = WrappedColumnar . _testTaskT_payload 59 | , _task_checkedOutBy = testTaskT_checkedOutBy 60 | , _task_hasRun = testTaskT_finished 61 | , _task_result = lens 62 | (\t -> WrappedColumnar (_testTaskT_result t)) 63 | (\t (WrappedColumnar s) -> t 64 | { _testTaskT_result = s 65 | } 66 | ) 67 | } 68 | 69 | createTask :: Int64 -> TestTask 70 | createTask i = TestTaskT 71 | { _testTaskT_id = i 72 | , _testTaskT_checkedOutBy = Nothing 73 | , _testTaskT_payload = i 74 | , _testTaskT_result = Nothing 75 | , _testTaskT_finished = False 76 | } 77 | 78 | newtype TestTasksDb f = TestTasksDb 79 | { _testTasksDb_tasks :: f (TableEntity TestTaskT) 80 | } deriving (Generic) 81 | 82 | instance Database be TestTasksDb 83 | 84 | tasksDb :: DatabaseSettings be TestTasksDb 85 | tasksDb = defaultDbSettings 86 | 87 | tasksDbPostgres :: BA.AnnotatedDatabaseSettings Postgres TestTasksDb 88 | tasksDbPostgres = BA.defaultAnnotatedDbSettings tasksDb 89 | 90 | taskSchema :: BA.Schema 91 | taskSchema = BA.fromAnnotatedDbSettings tasksDbPostgres (Proxy @'[]) 92 | 93 | data TestException = TestException 94 | deriving (Eq, Show, Typeable) 95 | 96 | instance Exception TestException 97 | -------------------------------------------------------------------------------- /beam/task/backend/test/Utils.hs: -------------------------------------------------------------------------------- 1 | {-# Language FlexibleContexts #-} 2 | module Utils where 3 | 4 | import Control.Concurrent (forkIO) 5 | import Control.Exception (throw) 6 | import Control.Monad (void, when) 7 | import Data.Int (Int64) 8 | import Data.IORef 9 | import Data.Text (Text) 10 | import Database.Beam 11 | import Database.Beam.Backend.SQL.SQL92 12 | import Database.Beam.Postgres 13 | import Database.Beam.Postgres.Syntax 14 | import Database.PostgreSQL.Simple 15 | import Database.PostgreSQL.Serializable 16 | import Rhyolite.Task.Beam.Worker 17 | import Types 18 | 19 | type Work = Int64 -> Pg (IO (Pg (WrappedColumnar (Maybe Bool) Identity))) 20 | 21 | insertTestTasks :: Connection -> [TestTask] -> IO () 22 | insertTestTasks c = runBeamPostgres c . runInsert . insert (_testTasksDb_tasks tasksDb) . insertValues 23 | 24 | createTaskWorker :: Connection -> Work -> Text -> IO Bool 25 | createTaskWorker conn work wId = taskWorker 26 | conn 27 | (_testTasksDb_tasks tasksDb) 28 | testTask 29 | (\_ -> work . unWrappedColumnar) 30 | wId 31 | 32 | allTestTasks :: Connection -> IO [TestTask] 33 | allTestTasks c = 34 | runBeamPostgres c $ runSelectReturningList $ 35 | select $ all_ (_testTasksDb_tasks tasksDb) 36 | 37 | justOneTestTask :: Connection -> IO (Maybe TestTask) 38 | justOneTestTask c = 39 | runBeamPostgres c $ runSelectReturningOne $ 40 | select $ all_ (_testTasksDb_tasks tasksDb) 41 | 42 | spawnTaskWorker :: Work -> Connection -> Text -> IO () 43 | spawnTaskWorker work conn = void . forkIO . void . createTaskWorker conn work 44 | 45 | toggleBoolIORef :: IORef Bool -> Bool -> Work 46 | toggleBoolIORef boolRef flag = const $ pure $ do 47 | when flag $ throw TestException 48 | atomicModifyIORef boolRef (\b -> (not b, ())) 49 | pure $ pure $ WrappedColumnar (Just True) 50 | -------------------------------------------------------------------------------- /beam/task/types/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /beam/task/types/rhyolite-beam-task-worker-types.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-beam-task-worker-types 3 | version: 0.1.0.0 4 | synopsis: Types for beam based task workers. 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: 16 | Rhyolite.Task.Beam 17 | 18 | build-depends: 19 | base 20 | , beam-core 21 | , lens 22 | 23 | hs-source-dirs: src 24 | default-language: Haskell2010 25 | ghc-options: -Wall 26 | -------------------------------------------------------------------------------- /beam/task/types/src/Rhyolite/Task/Beam.hs: -------------------------------------------------------------------------------- 1 | {-# Language DeriveAnyClass #-} 2 | {-# Language DeriveGeneric #-} 3 | {-# Language FlexibleInstances #-} 4 | {-# Language FunctionalDependencies #-} 5 | {-# Language KindSignatures #-} 6 | {-# Language RankNTypes #-} 7 | {-# Language StandaloneDeriving #-} 8 | {-# Language TemplateHaskell #-} 9 | {-| 10 | Description : Models the interface to a work queue that is stored in a database table. 11 | 12 | The 'Task' type in this module describes how to interact with a 'Table' that tracks tasks that can be checked out and processed by workers in a distributed but serializable fashion. In this model tasks are not locked by workers but are instead atomically checked out for processing and then later atomically checked in when processing is done. 13 | -} 14 | module Rhyolite.Task.Beam where 15 | 16 | import Control.Lens 17 | 18 | import Database.Beam 19 | 20 | -- | The Task type describes how to use a database table to check out tasks 21 | -- and record back the results after processing them. 22 | -- 23 | -- One can use 'Rhyolite.Task.Beam.Worker.taskWorker' to process a job 24 | -- given a 'Task' representing the job queue's interface. 25 | data Task be table payload checkout result = Task 26 | { _task_filter :: forall s. table (QExpr be s) -> QExpr be s Bool 27 | -- ^ A user-supplied predicate for choosing suitable tasks. This 28 | -- is combined with the logic that handles checkout and checkin 29 | -- in 'Rhyolite.Task.Beam.Worker.taskWorker'. 30 | , _task_payload :: forall s. table (QExpr be s) -> payload (QExpr be s) 31 | -- ^ How to extract the payload from the row 32 | , _task_checkedOutBy :: forall x. Lens' (table x) (C x (Maybe checkout)) 33 | -- ^ How the field which records a checkout is embedded within a row; 34 | -- a lens allows both reading and writing. 35 | , _task_hasRun :: forall x. Lens' (table x) (C x Bool) 36 | -- ^ Which field indicates that the task result has been checked in. 37 | , _task_result :: forall x. Lens' (table x) (result x) 38 | -- ^ How the result data is embedded within a row. 39 | -- Note that it has to be a 'Beamable' type in its own right, so if the 40 | -- result is a mere 'Columnar' type it should be wrapped in a newtype. 41 | } 42 | 43 | -- | The TaskWithoutHasRun type describes how to use a database table to check out tasks 44 | -- and record back the results after processing them. 45 | -- 46 | -- One can use 'Rhyolite.Task.Beam.Worker.taskWorkerWithoutHasRun' to process a job 47 | -- given a 'TaskWithoutHasRun' representing the job queue's interface. 48 | data TaskWithoutHasRun be table payload checkout result = TaskWithoutHasRun 49 | { _taskWithoutHasRun_filter :: forall s. table (QExpr be s) -> QExpr be s Bool 50 | -- ^ A user-supplied predicate for choosing suitable tasks. This 51 | -- is combined with the logic that handles checkout and checkin 52 | -- in 'Rhyolite.Task.Beam.Worker.taskWorker'. 53 | , _taskWithoutHasRun_payload :: forall s. table (QExpr be s) -> payload (QExpr be s) 54 | -- ^ How to extract the payload from the row 55 | , _taskWithoutHasRun_checkedOutBy :: forall x. table x -> C x (Maybe checkout) 56 | -- ^ How the field which records a checkout is embedded within a row; 57 | -- a lens allows both reading and writing. 58 | , _taskWithoutHasRun_result :: forall s. table (QField s) -> result (QField s) 59 | -- ^ How the result data is embedded within a row. 60 | -- Note that it has to be a 'Beamable' type in its own right, so if the 61 | -- result is a mere 'Columnar' type it should be wrapped in a newtype. 62 | } 63 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: 2 | account/backend/ 3 | account/types/ 4 | backend/ 5 | beam/db/ 6 | beam/task/backend/ 7 | beam/task/types/ 8 | common/ 9 | semimap/ 10 | email/ 11 | email/mime-mail-orphans/ 12 | frontend/ 13 | notify-listen/notify-listen-beam/ 14 | notify-listen/notify-listen/ 15 | psql-extras/psql-serializable/ 16 | psql-extras/psql-simple-beam/ 17 | psql-extras/psql-simple-class/ 18 | signed-data/signed-data/ 19 | signed-data/signed-data-clientsession/ 20 | test/ 21 | widgets/ 22 | -------------------------------------------------------------------------------- /common/Data/Vessel/Void.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE KindSignatures #-} 2 | {-# LANGUAGE FlexibleInstances #-} 3 | {-# LANGUAGE TypeFamilies #-} 4 | {-# LANGUAGE DeriveGeneric #-} 5 | 6 | module Data.Vessel.Void where 7 | 8 | import Data.Aeson 9 | import GHC.Generics 10 | import Data.Functor.Const 11 | import Data.Functor.Identity 12 | import Data.Patch (Group(..)) 13 | import Data.Vessel.Class 14 | import Reflex.Query.Class 15 | 16 | -- | completly empty View. 17 | data VoidV (v :: * -> *) = VoidV 18 | deriving (Eq, Ord, Show, Generic) 19 | 20 | instance FromJSON (VoidV a) 21 | instance ToJSON (VoidV a) 22 | 23 | instance Semigroup (VoidV x) where 24 | _ <> _ = VoidV 25 | instance Monoid (VoidV x) where 26 | mempty = VoidV 27 | instance Group (VoidV x) where 28 | negateG _ = VoidV 29 | 30 | instance View VoidV where 31 | 32 | instance Query (VoidV (Const x)) where 33 | type QueryResult (VoidV (Const x)) = VoidV Identity 34 | crop _ = id 35 | 36 | instance EmptyView VoidV where 37 | emptyV = VoidV 38 | -------------------------------------------------------------------------------- /common/Rhyolite/Aeson/Orphans.hs: -------------------------------------------------------------------------------- 1 | {-# Language CPP #-} 2 | {-# Language OverloadedStrings #-} 3 | {-# Language StandaloneDeriving #-} 4 | {-# options_ghc -fno-warn-orphans #-} 5 | module Rhyolite.Aeson.Orphans where 6 | 7 | #if !MIN_VERSION_aeson(1,5,1) && MIN_VERSION_these(1,1,0) 8 | -- The aeson instances for These are now in aeson instead of these. 9 | -- reflex-platform hasn't updated to a newer aeson yet. 10 | 11 | import Data.Aeson ((.=), FromJSON(..), ToJSON(..)) 12 | import qualified Data.Aeson as Aeson 13 | import qualified Data.Aeson.Encoding as Aeson (pair) 14 | import Data.Functor.These 15 | import qualified Data.HashMap.Strict as HM 16 | import Data.These 17 | 18 | -- | @since 0.7.1 19 | instance (ToJSON a, ToJSON b) => ToJSON (These a b) where 20 | toJSON (This a) = Aeson.object [ "This" .= a ] 21 | toJSON (That b) = Aeson.object [ "That" .= b ] 22 | toJSON (These a b) = Aeson.object [ "This" .= a, "That" .= b ] 23 | 24 | toEncoding (This a) = Aeson.pairs $ "This" .= a 25 | toEncoding (That b) = Aeson.pairs $ "That" .= b 26 | toEncoding (These a b) = Aeson.pairs $ "This" .= a <> "That" .= b 27 | 28 | -- | @since 0.7.1 29 | instance (FromJSON a, FromJSON b) => FromJSON (These a b) where 30 | parseJSON = Aeson.withObject "These a b" (p . HM.toList) 31 | where 32 | p [("This", a), ("That", b)] = These <$> parseJSON a <*> parseJSON b 33 | p [("That", b), ("This", a)] = These <$> parseJSON a <*> parseJSON b 34 | p [("This", a)] = This <$> parseJSON a 35 | p [("That", b)] = That <$> parseJSON b 36 | p _ = fail "Expected object with 'This' and 'That' keys only" 37 | 38 | -- | @since 0.7.2 39 | instance Aeson.ToJSON2 These where 40 | liftToJSON2 toa _ _tob _ (This a) = Aeson.object [ "This" .= toa a ] 41 | liftToJSON2 _toa _ tob _ (That b) = Aeson.object [ "That" .= tob b ] 42 | liftToJSON2 toa _ tob _ (These a b) = Aeson.object [ "This" .= toa a, "That" .= tob b ] 43 | 44 | liftToEncoding2 toa _ _tob _ (This a) = Aeson.pairs $ Aeson.pair "This" (toa a) 45 | liftToEncoding2 _toa _ tob _ (That b) = Aeson.pairs $ Aeson.pair "That" (tob b) 46 | liftToEncoding2 toa _ tob _ (These a b) = Aeson.pairs $ Aeson.pair "This" (toa a) <> Aeson.pair "That" (tob b) 47 | 48 | -- | @since 0.7.2 49 | instance ToJSON a => Aeson.ToJSON1 (These a) where 50 | liftToJSON _tob _ (This a) = Aeson.object [ "This" .= a ] 51 | liftToJSON tob _ (That b) = Aeson.object [ "That" .= tob b ] 52 | liftToJSON tob _ (These a b) = Aeson.object [ "This" .= a, "That" .= tob b ] 53 | 54 | liftToEncoding _tob _ (This a) = Aeson.pairs $ "This" .= a 55 | liftToEncoding tob _ (That b) = Aeson.pairs $ Aeson.pair "That" (tob b) 56 | liftToEncoding tob _ (These a b) = Aeson.pairs $ "This" .= a <> Aeson.pair "That" (tob b) 57 | 58 | -- | @since 0.7.2 59 | instance Aeson.FromJSON2 These where 60 | liftParseJSON2 pa _ pb _ = Aeson.withObject "These a b" (p . HM.toList) 61 | where 62 | p [("This", a), ("That", b)] = These <$> pa a <*> pb b 63 | p [("That", b), ("This", a)] = These <$> pa a <*> pb b 64 | p [("This", a)] = This <$> pa a 65 | p [("That", b)] = That <$> pb b 66 | p _ = fail "Expected object with 'This' and 'That' keys only" 67 | 68 | -- | @since 0.7.2 69 | instance FromJSON a => Aeson.FromJSON1 (These a) where 70 | liftParseJSON pb _ = Aeson.withObject "These a b" (p . HM.toList) 71 | where 72 | p [("This", a), ("That", b)] = These <$> parseJSON a <*> pb b 73 | p [("That", b), ("This", a)] = These <$> parseJSON a <*> pb b 74 | p [("This", a)] = This <$> parseJSON a 75 | p [("That", b)] = That <$> pb b 76 | p _ = fail "Expected object with 'This' and 'That' keys only" 77 | 78 | #else 79 | #endif 80 | -------------------------------------------------------------------------------- /common/Rhyolite/Api.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Request/response API protocol 3 | 4 | This commonly used module details the mechanism that we use to bring 5 | information from the frontend to the backend of a typical Obelisk app. The 6 | important datatype here is 'ApiRequest' which is parametrized on three 7 | application-dependent datatypes: one, of kind @*@, for credentials, and two, 8 | of kind @k -> *@, respectively for the public and private part of the 9 | request. As an example, your app could have datatypes like: 10 | 11 | @ 12 | data Credentials where 13 | ... 14 | 15 | data PublicRequest a where 16 | PublicRequest_Login :: Email -> Password -> PublicRequest LoginResult 17 | ... 18 | 19 | data PrivateRequest a where 20 | PrivateRequest_BuyItem :: Item -> PrivateRequest () 21 | PrivateRequest_CheckOut :: Item -> PrivateRequest CheckoutResult 22 | ... 23 | @ 24 | 25 | and you'll work with an 'ApiRequest' of type: 26 | 27 | @ 28 | ApiRequest Credentials PublicRequest PrivateRequest 29 | @ 30 | -} 31 | {-# Language ConstraintKinds #-} 32 | {-# Language FlexibleContexts #-} 33 | {-# Language FlexibleInstances #-} 34 | {-# Language GADTs #-} 35 | {-# Language LambdaCase #-} 36 | {-# Language MultiParamTypeClasses #-} 37 | {-# Language PolyKinds #-} 38 | {-# Language TypeFamilies #-} 39 | 40 | module Rhyolite.Api where 41 | 42 | import Data.Aeson 43 | import Data.Constraint.Extras 44 | import Data.Constraint.Forall 45 | import Data.Some 46 | 47 | -- | JSON encoding and decoding constraints on requests 48 | type Request r = (ForallF ToJSON r, Has ToJSON r, FromJSON (Some r), Has FromJSON r) 49 | 50 | -- | In most applications, API requests are either authenticated or 51 | -- unauthenticated. A login request, for example, is usually unauthenticated, 52 | -- or "public." 53 | data ApiRequest :: * -> (k -> *) -> (k -> *) -> k -> * where 54 | ApiRequest_Public :: public a -> ApiRequest cred public private a 55 | ApiRequest_Private :: cred -> private a -> ApiRequest cred public private a 56 | deriving (Show) 57 | 58 | instance (Has c public, Has c private) => Has c (ApiRequest cred public private) where 59 | argDict = \case 60 | ApiRequest_Public x -> argDict x 61 | ApiRequest_Private _ x -> argDict x 62 | 63 | -- | Shorthand constructor for public requests 64 | public :: public t -> ApiRequest cred public private t 65 | public = ApiRequest_Public 66 | 67 | -- | Shorthand constructor for private requests 68 | private :: cred -> private t -> ApiRequest cred public private t 69 | private = ApiRequest_Private 70 | 71 | instance (ToJSON cred, ToJSON (public a), ToJSON (private a)) => ToJSON (ApiRequest cred public private a) where 72 | toJSON = \case 73 | ApiRequest_Public x -> toJSON ("public", toJSON x) 74 | ApiRequest_Private c x -> toJSON ("private", (toJSON x, toJSON c)) 75 | 76 | instance (FromJSON cred, FromJSON (Some public), FromJSON (Some private)) => FromJSON (Some (ApiRequest cred public private)) where 77 | parseJSON v = do 78 | (tag,rest) <- parseJSON v 79 | case tag of 80 | "public" -> do 81 | Some x <- parseJSON rest 82 | return (Some (ApiRequest_Public x)) 83 | "private" -> do 84 | (Some x, c) <- parseJSON rest 85 | return (Some (ApiRequest_Private c x)) 86 | _ -> fail "Request appears neither public nor private" 87 | -------------------------------------------------------------------------------- /common/Rhyolite/App.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | 'Query' helpers 4 | 5 | Miscellaneous utilities related to the 'Query' datatype, and the definition of 6 | 'Single', a view for a single piece of data. 7 | -} 8 | {-# Language DeriveFoldable #-} 9 | {-# Language DeriveGeneric #-} 10 | {-# Language DeriveTraversable #-} 11 | {-# Language FlexibleContexts #-} 12 | {-# Language GeneralizedNewtypeDeriving #-} 13 | {-# Language PatternGuards #-} 14 | {-# Language PolyKinds #-} 15 | {-# Language ScopedTypeVariables #-} 16 | {-# Language StandaloneDeriving #-} 17 | {-# Language TypeApplications #-} 18 | {-# Language UndecidableInstances #-} 19 | 20 | {-# OPTIONS_GHC -fno-warn-orphans #-} 21 | 22 | module Rhyolite.App where 23 | 24 | import Data.Aeson (FromJSON, ToJSON) 25 | import Data.Align 26 | import qualified Data.AppendMap as MonoidalMap 27 | import Data.Constraint.Extras 28 | import qualified Data.Dependent.Map as DMap' 29 | import qualified Data.Dependent.Map.Monoidal as DMap 30 | import Data.Dependent.Sum 31 | import Data.Functor.Compose 32 | import Data.Functor.Const 33 | import Data.Functor.Identity 34 | import Data.GADT.Compare 35 | import Data.Map (Map) 36 | import Data.Map.Monoidal (MonoidalMap) 37 | import qualified Data.Map.Monoidal as MMap 38 | import Data.Proxy 39 | import Data.Semigroup (Semigroup) 40 | import qualified Data.Semigroup as Semigroup 41 | import Data.Semigroup (First(..)) 42 | import Data.These 43 | import Data.These.Combinators 44 | import Data.Typeable (Typeable) 45 | import Data.Vessel 46 | import Data.Vessel.Void 47 | import Data.Vessel.Internal (VSum(..)) 48 | import Data.Witherable (Filterable(..)) 49 | import GHC.Generics (Generic) 50 | import Reflex.Query.Class 51 | 52 | -- | Set-subtraction operation for queries. 53 | class PositivePart q where 54 | positivePart :: q -> Maybe q 55 | -- ^ Filter a query to only those parts which are selected a positive 56 | -- amount. 57 | 58 | instance PositivePart SelectedCount where 59 | positivePart x 60 | | x > 0 = Just x 61 | | otherwise = Nothing 62 | 63 | instance PositivePart () where 64 | positivePart _ = Nothing 65 | 66 | instance PositivePart a => PositivePart (Identity a) where 67 | positivePart (Identity x) = Identity <$> positivePart x 68 | 69 | instance PositivePart x => PositivePart (Const x a) where 70 | positivePart (Const x) = Const <$> positivePart x 71 | 72 | instance PositivePart (Proxy a) where 73 | positivePart _ = Nothing 74 | 75 | instance (PositivePart a, PositivePart b) => PositivePart (These a b) where 76 | positivePart ab = align (positivePart =<< justHere ab) (positivePart =<< justThere ab) 77 | 78 | instance PositivePart (f (g x)) => PositivePart (Compose f g x) where 79 | positivePart (Compose xs) = Compose <$> positivePart xs 80 | 81 | instance Has' PositivePart f g => PositivePart (DSum f g) where 82 | positivePart (f :=> g) = (f :=>) <$> has' @PositivePart @g f (positivePart g) 83 | 84 | instance (GCompare f, Has' PositivePart f g) => PositivePart (DMap'.DMap f g) where 85 | positivePart xs 86 | | DMap'.null xs' = Nothing 87 | | otherwise = Just xs' 88 | where xs' = DMap'.mapMaybeWithKey (\f g -> has' @PositivePart @g f (positivePart g)) xs 89 | 90 | deriving instance (GCompare f, Has' PositivePart f g) => PositivePart (DMap.MonoidalDMap f g) 91 | 92 | instance HasV PositivePart f g => PositivePart (VSum f g) where 93 | positivePart (f :~> g) = (f :~>) <$> hasV @PositivePart @g f (positivePart g) 94 | 95 | deriving instance PositivePart (g (First (Maybe v))) => PositivePart (SingleV v g) 96 | 97 | deriving instance PositivePart (g v) => PositivePart (MapV k v g) 98 | deriving instance PositivePart (g v) => PositivePart (IdentityV v g) 99 | deriving instance PositivePart (g f) => PositivePart (FlipAp f g) 100 | deriving instance (GCompare f, Has' PositivePart f (FlipAp g)) => PositivePart (Vessel f g) 101 | deriving instance (Ord k, PositivePart (f g)) => PositivePart (SubVessel k f g) 102 | instance PositivePart (VoidV x) where positivePart = const Nothing 103 | 104 | instance PositivePart a => PositivePart [a] where positivePart = composePositivePart 105 | instance PositivePart a => PositivePart (Maybe a) where positivePart = composePositivePart 106 | instance PositivePart a => PositivePart (Map k a) where positivePart = composePositivePart 107 | deriving instance PositivePart a => PositivePart (MMap.MonoidalMap k a) 108 | 109 | -- | This can be used to implement an instance of PositivePart for 110 | -- Functor-style queries/views, in terms of the other instances already 111 | -- required for those. 112 | standardPositivePart :: (Eq (q a), Monoid (q a), Num a, Ord a, Filterable q) => q a -> Maybe (q a) 113 | standardPositivePart x = 114 | let u = mapMaybe (\n -> if n > 0 then Just n else Nothing) x 115 | in if u == mempty then Nothing else Just u 116 | 117 | -- | Map 'positivePart' over a structure. If the resulting structure is empty, 118 | -- 'Nothing' is returned 119 | composePositivePart :: (Foldable q, PositivePart a, Filterable q) => q a -> Maybe (q a) 120 | composePositivePart x = 121 | let u = mapMaybe positivePart x 122 | in if null u then Nothing else Just u 123 | {-# INLINE composePositivePart #-} 124 | 125 | -- | Converts between a query and a map of queries with at most one element 126 | -- (at the provided key) 127 | singletonQuery :: (Monoid (QueryResult q), Ord k) => k -> QueryMorphism q (MonoidalMap k q) 128 | singletonQuery k = QueryMorphism 129 | { _queryMorphism_mapQuery = MonoidalMap.singleton k 130 | , _queryMorphism_mapQueryResult = MonoidalMap.findWithDefault mempty k 131 | } 132 | 133 | {-# Deprecated cropView "Use 'crop' instead" #-} 134 | -- | See 'crop' 135 | cropView :: (Query q) => q -> QueryResult q -> QueryResult q 136 | cropView = crop 137 | 138 | -- | Specialized 'mapMaybe' for working with pairs where we only want to filter 139 | -- based on some property of the first element 140 | fmapMaybeFst :: Filterable f => (a -> Maybe b) -> f (a, c) -> f (b, c) 141 | fmapMaybeFst f = mapMaybe $ \(a, c) -> case f a of 142 | Nothing -> Nothing 143 | Just b -> Just (b, c) 144 | 145 | -- | A view for a single piece of data, supporting update and delete. 146 | newtype Single t a = Single { unSingle :: Maybe (Semigroup.First (Maybe t), a) } 147 | deriving (Eq, Ord, Show, Foldable, Traversable, Functor, Generic, Typeable) 148 | 149 | instance Semigroup a => Semigroup (Single t a) where 150 | (<>) (Single Nothing) y = y 151 | (<>) x (Single Nothing) = x 152 | (<>) (Single (Just (t, a))) (Single (Just (_t, a'))) = Single $ Just (t, a Semigroup.<> a') 153 | 154 | instance Semigroup a => Monoid (Single t a) where 155 | mempty = Single Nothing 156 | mappend = (Semigroup.<>) 157 | 158 | instance Filterable (Single t) where 159 | mapMaybe f (Single (Just (t, x))) | Just y <- f x = Single (Just (t, y)) 160 | mapMaybe _ _ = Single Nothing 161 | 162 | instance (FromJSON t, FromJSON a) => FromJSON (Single t a) 163 | instance (ToJSON t, ToJSON a) => ToJSON (Single t a) 164 | 165 | -- | Get the data out of a 'Single' 166 | getSingle :: Single t a -> Maybe t 167 | getSingle (Single (Just (Semigroup.First (Just t), _))) = Just t 168 | getSingle _ = Nothing 169 | 170 | -- | Wrap data in a 'Single' 171 | single :: Maybe t -> a -> Single t a 172 | single t a = Single $ Just (Semigroup.First t, a) 173 | -------------------------------------------------------------------------------- /common/Rhyolite/Concurrent.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Respawning workers 4 | 5 | Create worker threads that are respawned in case of error. 6 | -} 7 | 8 | {-# Language ScopedTypeVariables #-} 9 | 10 | module Rhyolite.Concurrent where 11 | 12 | import Control.Concurrent (forkIO, killThread, threadDelay) 13 | import Control.Concurrent.Async (waitCatch, withAsync) 14 | import Control.Exception (SomeException, try) 15 | import Control.Monad ((<=<), forever, void, when) 16 | import Control.Monad.IO.Class (MonadIO, liftIO) 17 | import Data.Either (isLeft) 18 | import Data.Semigroup ((<>)) 19 | 20 | 21 | -- | Perform a supervised operation with delays in between, on a separate thread. Return an IO action for killing the thread. 22 | worker :: (MonadIO m) 23 | => Int -- ^ Delay between operations, in microseconds 24 | -> IO a -- ^ Operation to perform 25 | -> m (IO ()) 26 | worker = taggedWorker "" 27 | 28 | -- | Perform a tagged, supervised operation with delays in between, on a separate thread. Return an IO action for killing the thread. 29 | taggedWorker 30 | :: MonadIO m 31 | => String -- ^ Tag for this worker: displayed in error messages 32 | -> Int 33 | -> IO a 34 | -> m (IO ()) 35 | taggedWorker tag delay x = return . killThread <=< liftIO . forkIO . supervise tag . void . forever $ 36 | x >> threadDelay delay 37 | 38 | -- | Runs an action forever, restarting it if it dies. 39 | supervise :: Show a => String -> IO a -> IO () 40 | supervise tag a = forever $ withAsync a $ \child -> do 41 | let msgPrefix = if null tag then "supervise: " else "supervise: " <> tag <> ": " 42 | result <- waitCatch child 43 | printResult :: Either SomeException () <- try $ putStrLn $ msgPrefix <> "child terminated with " <> show result <> "; restarting" 44 | threadDelay 1000000 45 | when (isLeft printResult) $ putStrLn $ msgPrefix <> "note: an exception was encountered when printing the previous result" 46 | -------------------------------------------------------------------------------- /common/Rhyolite/Route.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: Legacy application routes 3 | 4 | This module is still here for backward compatibility reasons. You should use 5 | "Obelisk.Route" instead. 6 | -} 7 | {-# Language CPP #-} 8 | {-# Language FlexibleInstances #-} 9 | {-# Language FunctionalDependencies #-} 10 | {-# Language GeneralizedNewtypeDeriving #-} 11 | {-# Language OverloadedStrings #-} 12 | {-# Language TypeFamilies #-} 13 | {-# Language UndecidableInstances #-} 14 | 15 | module Rhyolite.Route where 16 | 17 | import Control.Monad.Base 18 | import Control.Monad.Reader 19 | import Control.Monad.Trans.Control 20 | import Control.Monad.Trans.Except (ExceptT) 21 | import Control.Monad.Trans.Maybe (MaybeT) 22 | #if defined(VERSION_monad_logger) 23 | import Control.Monad.Logger 24 | #endif 25 | import Data.Aeson 26 | import qualified Data.ByteString as BS 27 | import qualified Data.ByteString.Lazy as LBS 28 | import Data.Default 29 | import qualified Data.Map as Map 30 | import Data.Map (Map) 31 | import qualified Data.Text as T 32 | import Data.Text.Encoding 33 | import Network.HTTP.Types.URI (parseQuery, renderQuery) 34 | import Network.URI 35 | 36 | -- | A class that can produce application-specific URLs 37 | class Monad m => MonadRoute r m | m -> r where 38 | routeToUrl :: r -> m URI 39 | 40 | -- | The protocol, hostname, and rest (e.g., port) of the app's configured route 41 | type RouteEnv = (String, String, String) 42 | 43 | -- | A transformer that carries around information about the route 44 | newtype RouteT r m a = RouteT { unRouteT :: ReaderT RouteEnv m a } 45 | deriving 46 | ( Functor, Applicative, Monad, MonadIO, MonadTrans 47 | #if defined(VERSION_monad_logger) 48 | , MonadLogger 49 | #endif 50 | ) 51 | 52 | instance MonadTransControl (RouteT r) where 53 | type StT (RouteT r) a = a 54 | liftWith f = RouteT . ReaderT $ \r -> f $ \t -> runRouteT t r 55 | restoreT = RouteT . ReaderT . const 56 | {-# INLINABLE liftWith #-} 57 | {-# INLINABLE restoreT #-} 58 | 59 | instance MonadBase b m => MonadBase b (RouteT r m) where 60 | liftBase = lift . liftBase 61 | 62 | instance (MonadBaseControl b m) => MonadBaseControl b (RouteT r m) where 63 | type StM (RouteT r m) a = ComposeSt (RouteT r) m a 64 | liftBaseWith = defaultLiftBaseWith 65 | restoreM = defaultRestoreM 66 | {-# INLINABLE liftBaseWith #-} 67 | {-# INLINABLE restoreM #-} 68 | 69 | -- | Runs a 'RouteT' action 70 | runRouteT :: RouteT r m a -> RouteEnv -> m a 71 | runRouteT = runReaderT . unRouteT 72 | 73 | instance (Monad m, ToJSON r, Default r, Eq r) => MonadRoute r (RouteT r m) where 74 | routeToUrl r = do 75 | routeEnv <- RouteT ask 76 | return $ routeToUrlDefault routeEnv r 77 | 78 | -- | Constructs a route using a 'RouteEnv' 79 | routeToUrlDefault :: (ToJSON r, Default r, Eq r) 80 | => RouteEnv 81 | -> r 82 | -> URI 83 | routeToUrlDefault (baseProto, baseHost, basePort) r = 84 | let base = URI baseProto (Just $ URIAuth "" baseHost basePort) "/" 85 | in base (routeToQuery r) "" --TODO: https 86 | 87 | -- | Constructs a query string containing json-encoded route information. 88 | -- Seriously, use 'Obelisk.Route' instead. 89 | routeToQuery :: (ToJSON r, Default r, Eq r) => r -> String 90 | routeToQuery r = if r == def 91 | then "" 92 | else T.unpack . decodeUtf8 $ renderQuery True [("x", Just $ LBS.toStrict $ encode r)] 93 | 94 | instance MonadRoute r m => MonadRoute r (ReaderT a m) where 95 | routeToUrl r = lift $ routeToUrl r 96 | 97 | instance MonadRoute r m => MonadRoute r (MaybeT m) where 98 | routeToUrl r = lift $ routeToUrl r 99 | 100 | instance MonadRoute r m => MonadRoute r (ExceptT a m) where 101 | routeToUrl r = lift $ routeToUrl r 102 | 103 | -- | A transformer that can produce route information for a subset of routes 104 | newtype SubRouteT r r' m a = SubRouteT (ReaderT (r' -> r) m a) 105 | deriving (Functor, Applicative, Monad, MonadIO) 106 | 107 | instance (MonadRoute r m) => MonadRoute r' (SubRouteT r r' m) where 108 | routeToUrl r = SubRouteT $ do 109 | routeConv <- ask 110 | lift $ routeToUrl $ routeConv r 111 | 112 | instance MonadTrans (SubRouteT r r') where 113 | lift = SubRouteT . lift 114 | 115 | -- | Runs a 'SubRouteT' action 116 | runSubRouteT :: SubRouteT r r' m a -> (r' -> r) -> m a 117 | runSubRouteT (SubRouteT a) = runReaderT a 118 | 119 | -- | Produce urls for a subdomain 120 | routeToSubdomainUrl :: (MonadRoute r m) => String -> r -> m URI 121 | routeToSubdomainUrl sub x = fmap (addSubdomain sub) (routeToUrl x) 122 | 123 | -- | Tacks a subdomain onto a given 'URI" 124 | addSubdomain :: String -> URI -> URI 125 | addSubdomain sub uri = uri 126 | { uriAuthority = fmap 127 | (\auth -> auth { uriRegName = sub ++ "." ++ uriRegName auth }) 128 | (uriAuthority uri) 129 | } 130 | 131 | -- | Get the json-encoded query parameter used by this module to store and 132 | -- convey route infromation 133 | getDefaultParam :: FromJSON b => Map BS.ByteString (Maybe BS.ByteString) -> Maybe b 134 | getDefaultParam params = do 135 | Just v <- Map.lookup (encodeUtf8 "x") params 136 | decodeStrict' v 137 | 138 | -- | JSON-decode route information 139 | decodeRoute :: (FromJSON r) => T.Text -> Maybe r 140 | decodeRoute t = do 141 | Just v <- Map.lookup (encodeUtf8 "x") (Map.fromList (parseQuery (encodeUtf8 t))) 142 | decodeStrict' v 143 | 144 | -- | Constructs a 'RouteEnv' object from a 'URI'. 145 | uriToRouteEnv 146 | :: URI 147 | -> Maybe RouteEnv 148 | uriToRouteEnv u = do 149 | let s = uriScheme u 150 | a <- uriAuthority u 151 | return (s, uriUserInfo a <> uriRegName a, uriPort a <> uriPath u <> uriQuery u <> uriFragment u) 152 | -------------------------------------------------------------------------------- /common/Rhyolite/Vessel/App.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TypeFamilies #-} 2 | {-# LANGUAGE FlexibleContexts #-} 3 | {-# LANGUAGE ConstraintKinds #-} 4 | 5 | module Rhyolite.Vessel.App where 6 | 7 | import Rhyolite.Vessel.Types 8 | import Rhyolite.Vessel.AuthenticatedV 9 | import Rhyolite.Api 10 | 11 | type RhyoliteAuthAppC app = HasRhyoliteAuth 12 | (AuthCredential app) 13 | (PublicV app) 14 | (PrivateV app) 15 | (PersonalV app) 16 | 17 | -- | optional class to help organise the types of a rhyolite authenticated app. 18 | class RhyoliteAuthAppC app => RhyoliteAuthApp app where 19 | type AuthCredential app :: * 20 | 21 | type PublicApi app :: (* -> *) 22 | type PrivateApi app :: (* -> *) 23 | 24 | type PrivateV app :: ((* -> *) -> *) 25 | type PersonalV app :: ((* -> *) -> *) 26 | type PublicV app :: ((* -> *) -> *) 27 | 28 | -- | The full view selector which has a public, private, and personal part. 29 | type FullAppV app = FullV (AuthCredential app) (PublicV app) (PrivateV app) (PersonalV app) 30 | 31 | -- | The full view selector from the point of view of a particular authenticated identity which 32 | -- may or may not be valid; the result of a query can fail. 33 | type FullAppAuthErrorV app = FullAuthErrorV (PublicV app) (PrivateV app) (PersonalV app) 34 | 35 | -- | The full view selector from the point of view of a particular authenticated identity which 36 | -- assumes that the identity is valid and so the query cannot fali. 37 | type FullAppAuthV app = AuthenticatedV (PublicV app) (PrivateV app) (PersonalV app) 38 | 39 | type FullApi app = ApiRequest (AuthCredential app) (PublicApi app) (PrivateApi app) 40 | type FullAuthApi app = ApiRequest () (PublicApi app) (PrivateApi app) 41 | -------------------------------------------------------------------------------- /common/Rhyolite/Vessel/ErrorV.hs: -------------------------------------------------------------------------------- 1 | {-| Description: Either-like container for views and errors -} 2 | module Rhyolite.Vessel.ErrorV 3 | ( module X 4 | ) where 5 | 6 | import Rhyolite.Vessel.ErrorV.Internal as X (ErrorV) 7 | import Rhyolite.Vessel.ErrorV.Internal as X hiding (ErrorV(..)) 8 | -------------------------------------------------------------------------------- /common/Rhyolite/Vessel/ErrorV/Internal.hs: -------------------------------------------------------------------------------- 1 | {-| Description: ErrorV implementation 2 | -} 3 | {-# Language ConstraintKinds #-} 4 | {-# Language DeriveGeneric #-} 5 | {-# Language FlexibleInstances #-} 6 | {-# Language GADTs #-} 7 | {-# Language GeneralizedNewtypeDeriving #-} 8 | {-# Language LambdaCase #-} 9 | {-# Language MultiParamTypeClasses #-} 10 | {-# Language PolyKinds #-} 11 | {-# Language RankNTypes #-} 12 | {-# Language StandaloneDeriving #-} 13 | {-# Language TemplateHaskell #-} 14 | {-# Language TypeFamilies #-} 15 | {-# Language UndecidableInstances #-} 16 | module Rhyolite.Vessel.ErrorV.Internal where 17 | 18 | import Data.Aeson 19 | import Data.Aeson.GADT.TH 20 | import Data.Constraint 21 | import Data.Constraint.Extras 22 | import Data.GADT.Compare 23 | import Data.GADT.Show 24 | import Data.Orphans () 25 | import Data.Patch 26 | import Data.Semigroup 27 | import Data.Semigroup.Commutative 28 | import Data.Type.Equality 29 | import Data.Vessel 30 | import Data.Vessel.Single 31 | import Data.Vessel.Vessel 32 | import GHC.Generics 33 | import Reflex.Query.Class 34 | 35 | import Rhyolite.App 36 | 37 | -- | An internal type which specifies the two pieces of an 'ErrorV' container. 38 | -- When constructing a query, only the View piece is present. When constructing 39 | -- a result, either the Error or View part will be present but never both. 40 | data ErrorVK err view (v :: (* -> *) -> *) where 41 | ErrorVK_Error :: ErrorVK err view (SingleV err) 42 | ErrorVK_View :: ErrorVK err view view 43 | 44 | deriveJSONGADT ''ErrorVK 45 | 46 | instance GEq (ErrorVK err view) where 47 | geq = \case 48 | ErrorVK_Error -> \case 49 | ErrorVK_Error -> Just Refl 50 | ErrorVK_View -> Nothing 51 | ErrorVK_View -> \case 52 | ErrorVK_Error -> Nothing 53 | ErrorVK_View -> Just Refl 54 | 55 | instance GCompare (ErrorVK err view) where 56 | gcompare = \case 57 | ErrorVK_Error -> \case 58 | ErrorVK_Error -> GEQ 59 | ErrorVK_View -> GLT 60 | ErrorVK_View -> \case 61 | ErrorVK_Error -> GGT 62 | ErrorVK_View -> GEQ 63 | 64 | instance (c (SingleV err), c view) => Has c (ErrorVK err view) where 65 | argDict = \case 66 | ErrorVK_Error -> Dict 67 | ErrorVK_View -> Dict 68 | 69 | deriving instance Show (ErrorVK e v a) 70 | 71 | instance GShow (ErrorVK e v) where 72 | gshowsPrec = showsPrec 73 | 74 | deriving instance (Show (v f), Show (f (First (Maybe e)))) => Show (ErrorV e v f) 75 | 76 | -- | A functor-parametric container which as a query will contain a value of the 77 | -- underlying view type and as a result may contain either an err value or a 78 | -- view result value. 79 | newtype ErrorV err view g = ErrorV { unErrorV :: Vessel (ErrorVK err view) g } 80 | deriving (Generic, EmptyView) 81 | 82 | deriving instance (Eq (g (First (Maybe err))), Eq (view g)) => Eq (ErrorV err view g) 83 | 84 | instance View view => View (ErrorV err view) 85 | 86 | instance (ToJSON (g (First (Maybe err))), ToJSON (view g)) => ToJSON (ErrorV err view g) 87 | instance (View view, FromJSON (g (First (Maybe err))), FromJSON (view g)) => FromJSON (ErrorV err view g) 88 | 89 | deriving instance (Has' Semigroup (ErrorVK err v) (FlipAp g), View v) => Semigroup (ErrorV err v g) 90 | deriving instance (Has' Semigroup (ErrorVK err v) (FlipAp g), View v) => Monoid (ErrorV err v g) 91 | 92 | deriving instance (Semigroup (g (First (Maybe err))), Semigroup (v g), View v) => Commutative (ErrorV err v g) 93 | deriving instance 94 | ( Semigroup (g (First (Maybe err))) 95 | , Group (g (First (Maybe err))) 96 | , Semigroup (v g) 97 | , Group (v g) 98 | , View v 99 | ) => Group (ErrorV err v g) 100 | deriving instance (PositivePart (g (First (Maybe err))), PositivePart (v g)) => PositivePart (ErrorV err v g) 101 | 102 | instance 103 | ( Semigroup (v Identity) 104 | , View v 105 | , QueryResult (v Proxy) ~ v Identity 106 | ) => Query (ErrorV err v Proxy) where 107 | type QueryResult (ErrorV err v Proxy) = ErrorV err v Identity 108 | crop (ErrorV s) (ErrorV r) = ErrorV $ crop s r 109 | 110 | instance 111 | ( Semigroup (v Identity) 112 | , View v 113 | , QueryResult (v (Const g)) ~ v Identity 114 | ) => Query (ErrorV err v (Const g)) where 115 | type QueryResult (ErrorV err v (Const g)) = ErrorV err v Identity 116 | crop (ErrorV s) (ErrorV r) = ErrorV $ crop s r 117 | 118 | instance 119 | ( View v 120 | , Has' Semigroup (ErrorVK err v) (FlipAp (Compose c (VesselLeafWrapper (QueryResult (Vessel (ErrorVK err v) g))))) 121 | , Query (Vessel (ErrorVK err v) g) 122 | ) => Query (ErrorV err v (Compose c g)) where 123 | type QueryResult (ErrorV err v (Compose c g)) = ErrorV err v (Compose c (VesselLeafWrapper (QueryResult (Vessel (ErrorVK err v) g)))) 124 | crop (ErrorV s) (ErrorV r) = ErrorV $ crop s r 125 | 126 | -- | Construct a query that registers interest in both the success and error parts of an ErrorV. 127 | queryErrorV :: (View v, Semigroup (v Proxy)) => v Proxy -> ErrorV e v Proxy 128 | queryErrorV v = ErrorV (singletonV ErrorVK_View v <> singletonV ErrorVK_Error (SingleV Proxy)) 129 | 130 | -- | Construct a query that registers interest in both the success and error parts of an ErrorV. 131 | queryErrorVConst :: (View v, Num x, Semigroup x, Semigroup (v (Const x))) => v (Const x) -> ErrorV e v (Const x) 132 | queryErrorVConst v = ErrorV (singletonV ErrorVK_View v <> singletonV ErrorVK_Error (SingleV (Const 1))) 133 | 134 | -- | The error part of the view will never be present 135 | successErrorV :: View v => v Identity -> ErrorV e v Identity 136 | successErrorV = ErrorV . singletonV ErrorVK_View 137 | 138 | -- | The successful part of the view will never be present 139 | failureErrorV :: e -> ErrorV e v Identity 140 | failureErrorV = ErrorV . singletonV ErrorVK_Error . SingleV . Identity . First . Just 141 | 142 | -- | Given an 'ErrorV' query and a way to provide a possibly failing result, 143 | -- construct an ErrorV result. 144 | buildErrorV 145 | :: (View v, Monad m) 146 | => (v Proxy -> m (Either e (v Identity))) 147 | -> ErrorV e v Proxy 148 | -> m (ErrorV e v Identity) 149 | buildErrorV f (ErrorV v) = case lookupV ErrorVK_View v of 150 | Nothing -> pure (ErrorV emptyV) 151 | Just v' -> f v' >>= \case 152 | Left err -> pure $ failureErrorV err 153 | Right val -> pure $ successErrorV val 154 | 155 | -- | Given an 'ErrorV' result, observe whether it is an error result 156 | -- or a result of the underlying view type. 157 | observeErrorV 158 | :: ErrorV e v Identity 159 | -> Maybe (Either e (v Identity)) 160 | observeErrorV (ErrorV v) = case lookupV ErrorVK_Error v of 161 | Nothing -> Right <$> lookupV ErrorVK_View v 162 | Just err -> case lookupSingleV err of 163 | Nothing -> Right <$> lookupV ErrorVK_View v 164 | Just e -> Just (Left e) 165 | 166 | -- | A 'Path' which abstracts over constructing the query and observing the result. 167 | errorV :: (Semigroup (v (Const x)), View v, Num x, Semigroup x) 168 | => Path (v (Const x)) (ErrorV e v (Const x)) (ErrorV e v Identity) (Either e (v Identity)) 169 | errorV = Path { _path_to = queryErrorVConst, _path_from = observeErrorV } 170 | 171 | -- | Given an 'ErrorV' result, observe both error and result 172 | -- of the underlying view type. 173 | unsafeObserveErrorV 174 | :: ErrorV e v f 175 | -> (Maybe (f (First (Maybe e))), Maybe (v f)) 176 | unsafeObserveErrorV (ErrorV v) = 177 | let 178 | err = fmap unSingleV $ lookupV ErrorVK_Error v 179 | in (err, lookupV ErrorVK_View v) 180 | -------------------------------------------------------------------------------- /common/Rhyolite/Vessel/Path.hs: -------------------------------------------------------------------------------- 1 | {-# Language FlexibleInstances #-} 2 | {-# Language FunctionalDependencies #-} 3 | {-# Language RankNTypes #-} 4 | {-# Language GADTs #-} 5 | module Rhyolite.Vessel.Path 6 | (module Rhyolite.Vessel.Path, module Data.Vessel.Path) 7 | where 8 | 9 | import Data.Map (Map) 10 | import qualified Data.Map as Map 11 | import Data.Map.Monoidal 12 | import Data.Semigroup 13 | import Data.Vessel 14 | import Data.Vessel.Path 15 | import Rhyolite.SemiMap (SemiMap, getComplete) 16 | 17 | -- | 'Path' that will process the result and extract complete SemiMaps. 18 | semiMapP :: (Traversable f) => Path x x (f (SemiMap k v)) (f (Map k v)) 19 | semiMapP = postMap (traverse (fmap getMonoidalMap . getComplete)) 20 | 21 | -- | 'Path' that will process the result and collapse a Map of SemiMaps to extract just those 22 | -- which are complete. 23 | semiMapsP :: (Traversable f) => Path x x (f (Map k (SemiMap k' v))) (f (Map k (Map k' v))) 24 | semiMapsP = postMap (traverse (Just . Map.mapMaybe (fmap getMonoidalMap . getComplete))) 25 | 26 | -- TODO: Move to Data.Vessel.Path 27 | -- | 'Path' that will process the result and extract a 'First'. 28 | firstP :: Traversable f => Path x x (f (First v)) (f v) 29 | firstP = postMap (traverse (Just . getFirst)) 30 | 31 | -- TODO: Move to Data.Vessel.Path 32 | -- | 'Path' that will construct an empty view selector, and get nothing back. 33 | emptyPath :: Monoid m => Path a m m' a' 34 | emptyPath = Path (const mempty) (const Nothing) 35 | -------------------------------------------------------------------------------- /common/Rhyolite/Vessel/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ConstraintKinds #-} 2 | {-# LANGUAGE TypeFamilies #-} 3 | 4 | module Rhyolite.Vessel.Types where 5 | 6 | import Data.Vessel.Class 7 | import Data.Functor.Identity 8 | import Data.Functor.Const 9 | import Data.Patch (Group) 10 | import Reflex.Query.Class 11 | 12 | import Rhyolite.Vessel.AuthenticatedV 13 | import Rhyolite.Vessel.ErrorV 14 | import Rhyolite.Vessel.AuthMapV 15 | 16 | type RhyoliteAuthViewC v = 17 | ( View v 18 | , Group (v (Const SelectedCount)) 19 | , Eq (v (Const SelectedCount)) 20 | , Semigroup (v Identity) 21 | , QueryResult (v (Const SelectedCount)) ~ v Identity 22 | , EmptyView v 23 | ) 24 | 25 | type HasRhyoliteAuth token publicV privateV personalV = 26 | ( Ord token 27 | , RhyoliteAuthViewC publicV 28 | , RhyoliteAuthViewC privateV 29 | , RhyoliteAuthViewC personalV 30 | ) 31 | 32 | -- | The full view selector which has a public, private, and personal part. 33 | type FullV token publicV privateV personalV = AuthenticatedV publicV (AuthMapV token privateV) (AuthMapV token personalV) 34 | 35 | -- | The full view selector from the point of view of a particular authenticated identity which 36 | -- may or may not be valid; the result of a query can fail. 37 | type FullAuthErrorV publicV privateV personalV = AuthenticatedV publicV (ErrorV () privateV) (ErrorV () personalV) 38 | -------------------------------------------------------------------------------- /common/Rhyolite/WebSocket.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Websocket protocol for requests and queries 4 | 5 | The low-level implementation of the websocket communication between 6 | frontend and backend. You can use this manually, for example, when building a 7 | program that pretends to be a user of your app (load-testing comes to mind). 8 | -} 9 | 10 | {-# Language DeriveGeneric #-} 11 | {-# Language FlexibleContexts #-} 12 | {-# Language UndecidableInstances #-} 13 | 14 | module Rhyolite.WebSocket where 15 | 16 | import Data.Aeson 17 | import Data.Semigroup ((<>)) 18 | import Data.Some 19 | import Data.Text (Text) 20 | import Data.Typeable 21 | import GHC.Generics 22 | import Network.URI (URI(..)) 23 | import Reflex.Query.Class 24 | 25 | -- | Given an http or file uri, guesses what the websockets uri ought to be 26 | websocketUri :: URI -> URI 27 | websocketUri uri = uri 28 | { uriScheme = case uriScheme uri of 29 | "http:" -> "ws:" 30 | "https:" -> "wss:" 31 | "file:" -> "ws:" 32 | p -> error $ "Unrecognized protocol: " <> p 33 | } 34 | 35 | -- | Represents a WebSocket message from one of two channels: ViewSelector 36 | -- declarations or API requests 37 | data WebSocketRequest q r = WebSocketRequest_ViewSelector q 38 | | WebSocketRequest_Api (TaggedRequest r) 39 | deriving (Typeable, Generic) 40 | 41 | instance (FromJSON q, FromJSON (Some r)) => FromJSON (WebSocketRequest q r) 42 | instance (ToJSON q, ToJSON (Some r)) => ToJSON (WebSocketRequest q r) 43 | 44 | -- | Represents a WebSocket response from one of three channels: incoming 'View's, API responses, or version info 45 | data WebSocketResponse q = WebSocketResponse_View (QueryResult q) 46 | | WebSocketResponse_Api TaggedResponse 47 | | WebSocketResponse_Version Text 48 | deriving (Typeable, Generic) 49 | 50 | instance FromJSON (QueryResult q) => FromJSON (WebSocketResponse q) 51 | instance ToJSON (QueryResult q) => ToJSON (WebSocketResponse q) 52 | 53 | -- | A request tagged with an identifier 54 | data TaggedRequest r = TaggedRequest Int (Some r) 55 | deriving (Typeable, Generic) 56 | 57 | instance FromJSON (Some r) => FromJSON (TaggedRequest r) 58 | instance ToJSON (Some r) => ToJSON (TaggedRequest r) 59 | 60 | -- | A response tagged with an identifier matching the one in the 'TaggedRequest'. The identifier is the first argument. 61 | data TaggedResponse = TaggedResponse Int Value 62 | deriving (Typeable, Generic) 63 | 64 | instance FromJSON TaggedResponse 65 | instance ToJSON TaggedResponse 66 | -------------------------------------------------------------------------------- /common/rhyolite-common.cabal: -------------------------------------------------------------------------------- 1 | name: rhyolite-common 2 | version: 0.1 3 | license: BSD3 4 | copyright: Copyright (C) Obsidian Systems LLC 2018 5 | author: Obsidian Systems 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | build-type: Simple 9 | cabal-version: >=1.2 10 | synopsis: Common abstractions and datastructures. 11 | description: 12 | This package defines some common abstractions and datatypes used by 13 | rhyolite-backend and rhyolite-frontend. 14 | 15 | library 16 | hs-source-dirs: . 17 | build-depends: 18 | aeson 19 | , aeson-gadt-th 20 | , async 21 | , attoparsec 22 | , base 23 | , base-orphans >= 0.8.6 24 | , bytestring 25 | , commutative-semigroups 26 | , constraints 27 | , constraints-extras 28 | , containers 29 | , data-default 30 | , dependent-map 31 | , dependent-monoidal-map 32 | , dependent-sum 33 | , http-types 34 | , monad-control 35 | , monoidal-containers 36 | , mtl 37 | , network-uri 38 | , patch 39 | , reflex 40 | , semialign 41 | , semimap 42 | , some 43 | , text 44 | , these 45 | , transformers 46 | , transformers-base 47 | , unordered-containers 48 | , vessel 49 | , witherable 50 | 51 | exposed-modules: 52 | Data.Vessel.Void 53 | Rhyolite.Aeson.Orphans 54 | Rhyolite.Api 55 | Rhyolite.App 56 | Rhyolite.Concurrent 57 | Rhyolite.Route 58 | Rhyolite.Vessel.AuthenticatedV 59 | Rhyolite.Vessel.AuthMapV 60 | Rhyolite.Vessel.ErrorV 61 | Rhyolite.Vessel.ErrorV.Internal 62 | Rhyolite.Vessel.Path 63 | Rhyolite.Vessel.Types 64 | Rhyolite.Vessel.App 65 | Rhyolite.WebSocket 66 | 67 | reexported-modules: aeson-gadt-th:Data.Aeson.GADT.TH as Data.Aeson.GADT 68 | 69 | if (((!os(ios) && !arch(aarch64)) && !arch(arm)) && !impl(ghcjs -any)) 70 | build-depends: monad-logger 71 | 72 | ghc-options: 73 | -Wall -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields 74 | -O2 -fprof-auto-calls 75 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { obelisk ? import ./dep/obelisk (builtins.removeAttrs args ["pkgs" "inNixShell"]) 2 | , pkgs ? obelisk.nixpkgs 3 | , ... } @ args: 4 | 5 | let 6 | reflex-platform = obelisk.reflex-platform; 7 | inherit (pkgs) lib; 8 | haskellLib = pkgs.haskell.lib; 9 | repos = pkgs.thunkSet ./dep; 10 | 11 | # Some dependency thunks needed 12 | dep = import ./dep reflex-platform.hackGet; 13 | #TODO: Consider whether to prefer using thunkSet here. 14 | 15 | # Local packages. We override them below so that other packages can use them. 16 | rhyolitePackages = { 17 | rhyolite-backend = ./backend; 18 | rhyolite-beam-db = ./beam/db; 19 | rhyolite-beam-orphans = ./beam/orphans; 20 | rhyolite-beam-task-worker-types = ./beam/task/types; 21 | rhyolite-beam-task-worker-backend = ./beam/task/backend; 22 | rhyolite-notify-listen = ./notify-listen/notify-listen; 23 | rhyolite-notify-listen-beam = ./notify-listen/notify-listen-beam; 24 | psql-simple-class = ./psql-extras/psql-simple-class; 25 | psql-simple-beam = ./psql-extras/psql-simple-beam; 26 | psql-serializable = ./psql-extras/psql-serializable; 27 | rhyolite-common = ./common; 28 | rhyolite-email = ./email; 29 | mime-mail-orphans = ./email/mime-mail-orphans; 30 | semimap = ./semimap; 31 | rhyolite-frontend = ./frontend; 32 | signed-data = ./signed-data/signed-data; 33 | signed-data-clientsession = ./signed-data/signed-data-clientsession; 34 | rhyolite-widgets = ./widgets; 35 | rhyolite-account-backend = ./account/backend; 36 | rhyolite-account-types = ./account/types; 37 | }; 38 | 39 | # srcs used for overrides 40 | overrideSrcs = rhyolitePackages // { 41 | bytestring-aeson-orphans = repos.bytestring-aeson-orphans; 42 | monoid-map = repos.monoid-map; 43 | postgresql-simple-interpolate = repos.postgresql-simple-interpolate; 44 | 45 | # Newer versions than those in reflex-platform 46 | gargoyle = repos.gargoyle + "/gargoyle"; 47 | gargoyle-postgresql = repos.gargoyle + "/gargoyle-postgresql"; 48 | gargoyle-postgresql-connect = repos.gargoyle + "/gargoyle-postgresql-connect"; 49 | gargoyle-postgresql-nix = repos.gargoyle + "/gargoyle-postgresql-nix"; 50 | push-notifications = repos.push-notifications; 51 | vessel = repos.vessel; 52 | postgresql-lo-stream = repos.postgresql-lo-stream; 53 | beam-automigrate = repos.beam-automigrate; 54 | 55 | }; 56 | 57 | # You can use these manually if you don’t want to use rhyolite.project. 58 | # It will be needed if you need to combine with multiple overrides. 59 | haskellOverrides = lib.foldr lib.composeExtensions (_: _: {}) [ 60 | (self: super: lib.mapAttrs (name: path: self.callCabal2nix name path {}) overrideSrcs) 61 | (self: super: { 62 | frontend = super.frontend.override { 63 | obelisk-executable-config-lookup = self.obelisk-executable-config-lookup; 64 | }; 65 | gargoyle-postgresql-nix = haskellLib.overrideCabal super.gargoyle-postgresql-nix { 66 | librarySystemDepends = [ pkgs.postgresql ]; 67 | }; 68 | validation = haskellLib.dontCheck super.validation; 69 | postgresql-lo-stream = haskellLib.markUnbroken super.postgresql-lo-stream; 70 | 71 | HaskellNet-SSL = self.callHackage "HaskellNet-SSL" "0.3.4.4" {}; 72 | 73 | base-orphans = self.callHackageDirect { 74 | pkg = "base-orphans"; 75 | ver = "0.8.6"; 76 | sha256 = "sha256:17hplm1mgw65jbszg5z4vqk4i24ilxv8mbszr3s8lhpll5naik26"; 77 | } {}; 78 | 79 | aeson-qq = self.callHackage "aeson-qq" "0.8.4" {}; 80 | postgresql-syntax = haskellLib.dontCheck super.postgresql-syntax; 81 | vessel = haskellLib.doJailbreak super.vessel; 82 | monoid-map = haskellLib.doJailbreak super.monoid-map; 83 | 84 | beam-migrate = self.callHackage "beam-migrate" "0.5.2.0" {}; 85 | 86 | # 'locale' is broken on nix darwin which is required by postgres 'initdb' 87 | rhyolite-beam-task-worker-backend = if pkgs.stdenv.hostPlatform.isDarwin 88 | then 89 | haskellLib.dontCheck super.rhyolite-beam-task-worker-backend 90 | else 91 | super.rhyolite-beam-task-worker-backend; 92 | }) 93 | ]; 94 | 95 | in obelisk // { 96 | 97 | inherit haskellOverrides; 98 | 99 | rhyolitePackages = haskellPackages: builtins.intersectAttrs rhyolitePackages (haskellPackages.extend haskellOverrides); 100 | 101 | # Function similar to obelisk.project that handles overrides for you. 102 | project = base: projectDefinition: 103 | obelisk.project base ({...}@args: 104 | let def = projectDefinition args; 105 | in def // { 106 | overrides = lib.composeExtensions haskellOverrides (def.overrides or (_: _: {})); 107 | }); 108 | 109 | # Used to build this project. Should only be needed by CI, devs. 110 | proj = obelisk.reflex-platform.project ({ pkgs, ... }@args: { 111 | overrides = haskellOverrides; 112 | packages = { 113 | rhyolite-backend = ./backend; 114 | rhyolite-common = ./common; 115 | semimap = ./semimap; 116 | rhyolite-frontend = ./frontend; 117 | rhyolite-test-suite = ./test; 118 | }; 119 | shells = rec { 120 | ghc = [ 121 | "rhyolite-backend" 122 | "rhyolite-test-suite" 123 | ] ++ ghcjs; 124 | ghcjs = [ 125 | "rhyolite-common" 126 | "rhyolite-frontend" 127 | ]; 128 | }; 129 | tools = ghc: [ pkgs.postgresql ]; 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /dep/beam-automigrate/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/beam-automigrate/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "beam-automigrate", 4 | "branch": "develop", 5 | "private": false, 6 | "rev": "5bd03ad708583d7edcd5a796e7d6306bdd3919a8", 7 | "sha256": "1cl7z1srig1h7wzai583ggi4alc8ln39yrspvsagp9d94xrwads3" 8 | } 9 | -------------------------------------------------------------------------------- /dep/beam-automigrate/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/bytestring-aeson-orphans/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/bytestring-aeson-orphans/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "bytestring-aeson-orphans", 4 | "branch": "release/0.1.0.1", 5 | "private": false, 6 | "rev": "4d3c8d2344af18a0e486b07d574e41ad7e24a10c", 7 | "sha256": "17dhl97qsadn37pmvw5z9zjzwy750yis3wr88zqb1g4wfb95jv4h" 8 | } 9 | -------------------------------------------------------------------------------- /dep/bytestring-aeson-orphans/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/default.nix: -------------------------------------------------------------------------------- 1 | # Applies a given function to the subdirectories of this one. 2 | f: let inherit (builtins) readDir filter map listToAttrs getAttr attrNames; 3 | fs = readDir ./.; 4 | dirs = filter (x: getAttr x fs == "directory") (attrNames fs); 5 | in listToAttrs (map (d: { name = d; value = f (./. + ("/" + d)); }) dirs) 6 | -------------------------------------------------------------------------------- /dep/gargoyle/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/gargoyle/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "gargoyle", 4 | "branch": "develop", 5 | "private": false, 6 | "rev": "d9a2b0ab4297d883caef784c7c11891611a6d4b0", 7 | "sha256": "1rxqvy10bgzd3bldrv223m71j3nll297llcpaiz32nyzjdw2rwik" 8 | } 9 | -------------------------------------------------------------------------------- /dep/gargoyle/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/monoid-map/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/monoid-map/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "monoid-map", 4 | "branch": "release/0.2.0.0", 5 | "private": false, 6 | "rev": "620e5353713dd292c33d71462ed0835034612d2f", 7 | "sha256": "1aszp69b3wf247cjcy3qw18qrp91hay3dg7fwm6vgzpwdbpnf0yv" 8 | } 9 | -------------------------------------------------------------------------------- /dep/monoid-map/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/obelisk/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/obelisk/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "obelisk", 4 | "branch": "release/1.3.0.0", 5 | "private": false, 6 | "rev": "58c04270d606c061e7ffd2f16345e0f451eba600", 7 | "sha256": "167h4qkkc7rvhwlfbv3fdj3gd8jn5svdawh2vzpi04j9xlsw3jlf" 8 | } 9 | -------------------------------------------------------------------------------- /dep/obelisk/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/postgresql-lo-stream/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/postgresql-lo-stream/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "postgresql-lo-stream", 4 | "branch": "develop", 5 | "private": false, 6 | "rev": "ddf9778546ac90be5d74a8009d114c041f99cffb", 7 | "sha256": "1hqrjmqmn45c7ghda7g2dqkndg7s2nsf0d9vpvhrmbim9aw9gvbr" 8 | } 9 | -------------------------------------------------------------------------------- /dep/postgresql-lo-stream/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/postgresql-simple-interpolate/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/postgresql-simple-interpolate/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "postgresql-simple-interpolate", 4 | "branch": "master", 5 | "private": false, 6 | "rev": "fc34dd4d1e179b4dd8e2fa9419435d6f9bd29160", 7 | "sha256": "0631fhycn8silm25rbajqc4fmhhh6n6kcnl6b0nddzx0bdmf1v0b" 8 | } 9 | -------------------------------------------------------------------------------- /dep/postgresql-simple-interpolate/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /dep/push-notifications/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/push-notifications/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "push-notifications", 4 | "branch": "develop", 5 | "private": false, 6 | "rev": "7a6f836a5086c3271613fa96eee3f7a05c8e7a23", 7 | "sha256": "10kl2vpzjknqmp816rw2zmnnv01zjp2d4s2243skm70z0js0al8w" 8 | } 9 | -------------------------------------------------------------------------------- /dep/push-notifications/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import {}).fetchFromGitHub { 6 | inherit owner repo rev sha256 fetchSubmodules private; 7 | }; 8 | json = builtins.fromJSON (builtins.readFile ./github.json); 9 | in fetch json -------------------------------------------------------------------------------- /dep/vessel/default.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | import (import ./thunk.nix) -------------------------------------------------------------------------------- /dep/vessel/github.json: -------------------------------------------------------------------------------- 1 | { 2 | "owner": "obsidiansystems", 3 | "repo": "vessel", 4 | "branch": "release/0.3.0.0-r1", 5 | "private": false, 6 | "rev": "a0eafdd20b3844dd23e3b0ac537049c60b9049b0", 7 | "sha256": "19nn25sdr4hxvd9i81aqvx2hm7c6dg9zzb244rl21jp689nnkxqk" 8 | } 9 | -------------------------------------------------------------------------------- /dep/vessel/thunk.nix: -------------------------------------------------------------------------------- 1 | # DO NOT HAND-EDIT THIS FILE 2 | let fetch = { private ? false, fetchSubmodules ? false, owner, repo, rev, sha256, ... }: 3 | if !fetchSubmodules && !private then builtins.fetchTarball { 4 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; inherit sha256; 5 | } else (import (builtins.fetchTarball { 6 | url = "https://github.com/NixOS/nixpkgs/archive/3aad50c30c826430b0270fcf8264c8c41b005403.tar.gz"; 7 | sha256 = "0xwqsf08sywd23x0xvw4c4ghq0l28w2ki22h0bdn766i16z9q2gr"; 8 | }) {}).fetchFromGitHub { 9 | inherit owner repo rev sha256 fetchSubmodules private; 10 | }; 11 | json = builtins.fromJSON (builtins.readFile ./github.json); 12 | in fetch json -------------------------------------------------------------------------------- /email/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for rhyolite-email 2 | 3 | ## 0.1.0.0 -- YYYY-mm-dd 4 | 5 | * First version. Released on an unsuspecting world. 6 | -------------------------------------------------------------------------------- /email/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /email/mime-mail-orphans/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /email/mime-mail-orphans/mime-mail-orphans.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: mime-mail-orphans 3 | version: 0.1.0.0 4 | synopsis: Orphan instances for mime-mail 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Web 13 | 14 | library 15 | exposed-modules: 16 | Network.Mail.Mime.Orphans 17 | build-depends: 18 | base 19 | , aeson 20 | , bytestring-aeson-orphans 21 | , mime-mail 22 | 23 | hs-source-dirs: src 24 | default-language: Haskell2010 25 | ghc-options: -Wall 26 | -------------------------------------------------------------------------------- /email/mime-mail-orphans/src/Network/Mail/Mime/Orphans.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | {-# LANGUAGE StandaloneDeriving #-} 3 | {-# OPTIONS_GHC -Wno-orphans #-} 4 | 5 | module Network.Mail.Mime.Orphans where 6 | 7 | import ByteString.Aeson.Orphans () 8 | import Data.Aeson 9 | import Data.Aeson.TH 10 | import qualified Network.Mail.Mime as Mail 11 | 12 | deriveJSON defaultOptions ''Mail.Address 13 | deriveJSON defaultOptions ''Mail.Disposition 14 | deriveJSON defaultOptions ''Mail.Encoding 15 | deriveJSON defaultOptions ''Mail.Part 16 | deriveJSON defaultOptions ''Mail.PartContent 17 | deriveJSON defaultOptions ''Mail.Mail 18 | 19 | deriving instance Read Mail.Address 20 | -------------------------------------------------------------------------------- /email/rhyolite-email.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-email 3 | version: 0.1.0.0 4 | synopsis: Send emails from rhyolite applications 5 | description: Send emails over SMTP and build emails using blaze or reflex-dom 6 | homepage: https://github.com/obsidiansystems/rhyolite 7 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 8 | license: BSD-3-Clause 9 | license-file: LICENSE 10 | author: Obsidian Systems LLC 11 | maintainer: maintainer@obsidian.systems 12 | copyright: 2021 Obsidian Systems LLC 13 | category: Web 14 | extra-source-files: 15 | CHANGELOG.md 16 | email.css 17 | 18 | library 19 | exposed-modules: 20 | Rhyolite.Email 21 | , Rhyolite.Email.Orphans 22 | build-depends: 23 | base 24 | , aeson 25 | , blaze-html 26 | , bytestring-aeson-orphans 27 | , data-default 28 | , file-embed 29 | , HaskellNet 30 | , HaskellNet-SSL 31 | , mime-mail 32 | , monad-logger 33 | , mtl 34 | , network 35 | , obelisk-route 36 | , reflex 37 | , reflex-dom-core 38 | , rhyolite-common 39 | , signed-data 40 | , smtp-mail 41 | , text 42 | , time 43 | , timerep 44 | , transformers 45 | 46 | hs-source-dirs: src 47 | default-language: Haskell2010 48 | ghc-options: -Wall 49 | -------------------------------------------------------------------------------- /email/src/Rhyolite/Email.hs: -------------------------------------------------------------------------------- 1 | {-| Description: Build and send emails 2 | 3 | Utilities and templates to send emails from the backend. 4 | -} 5 | 6 | {-# Language DeriveGeneric #-} 7 | {-# Language GeneralizedNewtypeDeriving #-} 8 | {-# Language OverloadedStrings #-} 9 | {-# Language PolyKinds #-} 10 | {-# Language ScopedTypeVariables #-} 11 | {-# Language StandaloneDeriving #-} 12 | {-# Language TemplateHaskell #-} 13 | {-# Language UndecidableInstances #-} 14 | 15 | {-# OPTIONS_GHC -fno-warn-orphans #-} 16 | 17 | module Rhyolite.Email where 18 | 19 | import Control.Exception 20 | import Control.Monad.IO.Class 21 | import Control.Monad.Logger 22 | import Control.Monad.Reader (ReaderT, lift) 23 | import Control.Monad.Reader 24 | import Control.Monad.Trans.Except (ExceptT) 25 | import Control.Monad.Trans.Maybe (MaybeT) 26 | import Data.Aeson 27 | import Data.Default 28 | import Data.FileEmbed (makeRelativeToProject, embedFile) 29 | import Data.Foldable 30 | import Data.Functor.Identity (Identity, runIdentity) 31 | import Data.List.NonEmpty (NonEmpty) 32 | import Data.Maybe (maybeToList) 33 | import Data.Signed 34 | import Data.String (fromString) 35 | import Data.Text (Text) 36 | import qualified Data.Text as T 37 | import Data.Text.Encoding 38 | import qualified Data.Text.Lazy as LT 39 | import Data.Time.Clock 40 | import Data.Time.LocalTime 41 | import Data.Time.RFC2822 42 | import Data.Word 43 | import GHC.Generics (Generic) 44 | import Network.HaskellNet.Auth 45 | import Network.HaskellNet.SMTP.SSL hiding (sendMail) 46 | import qualified Network.HaskellNet.SMTP.SSL as HaskellNet 47 | import Network.Mail.Mime (Mail) 48 | import Network.Mail.Mime (Address(..), Mail(..), htmlPart, plainPart) 49 | import Network.Mail.SMTP (simpleMail) 50 | import Network.Socket (HostName, PortNumber) 51 | import Obelisk.Route.Frontend 52 | import Reflex 53 | import Reflex.Dom.Builder.Static 54 | import Rhyolite.Route 55 | import Text.Blaze.Html.Renderer.Text 56 | import Text.Blaze.Html5 ((!), Html) 57 | import qualified Text.Blaze.Html5 as H 58 | import Text.Blaze.Html5.Attributes 59 | import qualified Text.Blaze.Html5.Attributes as A 60 | 61 | -- | Errors that can arise while interacting with an SMTP server 62 | data EmailError 63 | = EmailError_AuthFailed 64 | -- ^ The SMTP server refused to authenticate 65 | | EmailError_Exception SMTPException 66 | -- ^ An error occurred while interacting with an SMTP server 67 | deriving (Generic, Show) 68 | 69 | instance Exception EmailError 70 | 71 | -- | A monad that can send emails 72 | class Monad m => MonadEmail m where 73 | sendMail :: Mail -> m (Either EmailError ()) 74 | 75 | instance MonadEmail m => MonadEmail (ReaderT r m) where 76 | sendMail = lift . sendMail 77 | 78 | instance MonadEmail m => MonadEmail (MaybeT m) where 79 | sendMail = lift . sendMail 80 | 81 | instance MonadEmail m => MonadEmail (ExceptT e m) where 82 | sendMail = lift . sendMail 83 | 84 | -- TODO upstream these orphan instances 85 | deriving instance Generic AuthType 86 | instance ToJSON AuthType 87 | instance FromJSON AuthType 88 | instance FromJSON PortNumber where 89 | parseJSON v = do 90 | n :: Word16 <- parseJSON v 91 | return $ fromIntegral n 92 | instance ToJSON PortNumber where 93 | toJSON n = toJSON (fromIntegral n :: Word16) 94 | 95 | -- | SMTP connection protocols 96 | data SMTPProtocol 97 | = SMTPProtocol_Plain 98 | | SMTPProtocol_SSL 99 | | SMTPProtocol_STARTTLS 100 | deriving (Show, Read, Eq, Ord, Generic) 101 | 102 | instance FromJSON SMTPProtocol 103 | instance ToJSON SMTPProtocol 104 | 105 | -- | Configuration for how to authenticate with a mail server 106 | data EmailAuth = EmailAuth 107 | { _emailAuth_authType :: AuthType 108 | , _emailAuth_username :: Text 109 | , _emailAuth_password :: Text 110 | } deriving (Show, Eq, Generic) 111 | 112 | instance ToJSON EmailAuth 113 | instance FromJSON EmailAuth 114 | 115 | -- | Configuration for how to find an email server, and how to 116 | -- authenticate with it. 117 | data EmailConfig = EmailConfig 118 | { _emailConfig_hostname :: HostName -- ^ E.g., "smtp.server.com" 119 | , _emailConfig_port :: PortNumber 120 | , _emailConfig_protocol :: SMTPProtocol 121 | , _emailConfig_emailAuth :: Maybe EmailAuth 122 | } deriving (Show, Eq, Generic) 123 | 124 | instance ToJSON EmailConfig 125 | instance FromJSON EmailConfig 126 | 127 | -- | Send an email using the provided connection info. This function ignores 128 | -- send errors. 129 | sendEmail :: EmailConfig -> Mail -> IO (Either EmailError ()) 130 | sendEmail ee m = withSMTP ee $ HaskellNet.sendMail m 131 | 132 | -- | Run an IO action that expects an active connection to an smtp server. 133 | -- The action returns an error if that connection cannot be established. 134 | withSMTP :: EmailConfig -> (SMTPConnection -> IO a) -> IO (Either EmailError a) 135 | withSMTP cfg send = do 136 | let hostname = _emailConfig_hostname cfg 137 | port = _emailConfig_port cfg 138 | go conn = case _emailConfig_emailAuth cfg of 139 | Nothing -> Right <$> send conn 140 | Just (EmailAuth authType un pw) -> do 141 | loginResult <- authenticate authType (T.unpack un) (T.unpack pw) conn 142 | if loginResult 143 | then Right <$> send conn 144 | else pure $ Left EmailError_AuthFailed 145 | er <- try $ case _emailConfig_protocol cfg of 146 | SMTPProtocol_Plain -> doSMTPPort hostname port go 147 | SMTPProtocol_STARTTLS -> doSMTPSTARTTLSWithSettings hostname (defaultSettingsSMTPSTARTTLS { sslPort = port }) go 148 | SMTPProtocol_SSL -> doSMTPSSLWithSettings hostname (defaultSettingsSMTPSSL { sslPort = port }) go 149 | case er of 150 | Left err -> pure $ Left (EmailError_Exception err) 151 | Right r -> pure $ r 152 | 153 | -- | A monad transformer that can send emails 154 | newtype EmailT m a = EmailT { unEmailT :: ReaderT EmailConfig m a } 155 | deriving 156 | ( Functor 157 | , Applicative 158 | , Monad 159 | , MonadIO 160 | , MonadRoute r 161 | , MonadSign 162 | , MonadTrans 163 | , MonadLogger 164 | ) 165 | 166 | instance MonadIO m => MonadEmail (EmailT m) where 167 | sendMail mail = do 168 | env <- EmailT ask 169 | liftIO $ putStrLn $ mconcat 170 | [ "Sending email " 171 | , show (map snd $ filter ((=="Subject") . fst) $ mailHeaders mail) 172 | , " to " 173 | , show (map addressEmail $ mailTo mail) 174 | ] 175 | liftIO $ sendEmail env mail 176 | 177 | -- | Run an 'EmailT' action 178 | runEmailT :: EmailT m a -> EmailConfig -> m a 179 | runEmailT = runReaderT . unEmailT 180 | 181 | -- | Send an email, specifiying the "from" fields 182 | sendEmailFrom :: MonadEmail m 183 | => Text -- ^ Sender name 184 | -> Text -- ^ Sender email 185 | -> NonEmpty Text -- ^ Recipients 186 | -> Text -- ^ Subject line 187 | -> Html -- ^ Body of message 188 | -> m (Either EmailError ()) 189 | sendEmailFrom name' email recipients sub body = 190 | sendMail $ simpleMail (Address (Just name') email) 191 | (map (Address Nothing) $ toList recipients) 192 | [] 193 | [] 194 | sub 195 | [htmlPart $ renderHtml body] 196 | 197 | -- | Configuration for app-specific emails 198 | data WidgetEmailCfg br fr = WidgetEmailCfg 199 | { _widgetEmailName :: Text 200 | -- ^ Name to use in the "from:" field. 201 | , _widgetEmailAddress :: Text 202 | -- ^ Email address to use in the "from:" field. 203 | , _widgetEmailBaseURL :: Text 204 | -- ^ Base URL to build URLs for 205 | , _widgetEmailRouteEncoder :: (Encoder Identity Identity (R (FullRoute br fr)) PageName) 206 | -- ^ Encoder to use for routes; usually the project's checkedRouteEncoder 207 | } 208 | 209 | -- | Build and send an email using a StaticWidget that can use frontend routes. 210 | sendWidgetEmailFrom 211 | :: forall k (x :: k) a t r br m. 212 | (MonadIO m, MonadEmail m, Reflex t) 213 | => WidgetEmailCfg br r 214 | -- ^ Configuration for email sender 215 | -> NonEmpty Text 216 | -- ^ List of recipients 217 | -> Text 218 | -- ^ Subject line 219 | -> Maybe ((RouteToUrlT (R r) Identity) Text) 220 | -- ^ Body plaintext, with route decoder 221 | -> SetRouteT t (R r) (RouteToUrlT (R r) (StaticWidget x)) a 222 | -- ^ Body widget for the email 223 | -> m (Either EmailError ()) 224 | sendWidgetEmailFrom cfg recipients sub plainText bodyWidget = 225 | sendMail =<< widgetMail cfg recipients sub plainText bodyWidget 226 | 227 | -- | Build an email using a StaticWidget that can use frontend routes. 228 | widgetMail 229 | :: forall k (x :: k) a t r br m. 230 | (MonadIO m, MonadEmail m, Reflex t) 231 | => WidgetEmailCfg br r 232 | -- ^ Configuration for email sender 233 | -> NonEmpty Text 234 | -- ^ List of recipients 235 | -> Text 236 | -- ^ Subject line 237 | -> Maybe ((RouteToUrlT (R r) Identity) Text) 238 | -- ^ Body plaintext, with route decoder 239 | -> SetRouteT t (R r) (RouteToUrlT (R r) (StaticWidget x)) a 240 | -- ^ Body widget for the email 241 | -> m Mail 242 | widgetMail cfg recipients sub plainText bodyWidget = do 243 | let 244 | WidgetEmailCfg 245 | { _widgetEmailName = name' 246 | , _widgetEmailAddress = email 247 | , _widgetEmailBaseURL = baseUrl 248 | , _widgetEmailRouteEncoder = routeEncoder } = cfg 249 | renderRouteForEmail = (baseUrl <>) . renderFrontendRoute routeEncoder 250 | runEmailWidget = fmap snd . renderStatic . flip runRouteToUrlT renderRouteForEmail . runSetRouteT 251 | runEmailPlaintext = runIdentity . flip runRouteToUrlT renderRouteForEmail 252 | t <- liftIO $ getCurrentTime 253 | let formattedTime = formatTimeRFC2822 $ utcToZonedTime utc t 254 | body <- liftIO $ LT.fromStrict . decodeUtf8 <$> runEmailWidget bodyWidget 255 | let bodyText = LT.fromStrict . runEmailPlaintext <$> plainText 256 | -- XXX this can probably all be done applicatively 257 | return $ Mail 258 | (Address (Just name') email) 259 | (map (Address Nothing) $ toList recipients) 260 | [] 261 | [] 262 | [("Subject", sub), ("Date", formattedTime)] 263 | [maybeToList (plainPart <$> bodyText) <> [htmlPart body]] 264 | 265 | -- | A basic email template 266 | emailTemplate :: (MonadRoute r m, Default r) => Text -> Maybe Html -> Html -> Html -> Html -> m Html 267 | emailTemplate productName mStyleHtml titleHtml leadHtml contentHtml = 268 | emailTemplateSimple productName mStyleHtml titleHtml $ H.table $ do 269 | H.tr $ H.td $ H.h1 titleHtml 270 | H.hr 271 | H.tr $ H.td $ H.p ! class_ "lead" $ leadHtml 272 | H.hr 273 | H.tr $ H.td $ contentHtml 274 | 275 | -- | An email template with a predefined layout 276 | emailTemplateSimple :: (MonadRoute r m, Default r) => Text -> Maybe Html -> Html -> Html -> m Html 277 | emailTemplateSimple productName mStyleHtml titleHtml contentHtml = do 278 | indexLink <- routeToUrl def 279 | return $ H.docTypeHtml $ do 280 | H.head $ do 281 | H.style $ case mStyleHtml of 282 | Nothing -> H.toHtml $ decodeUtf8 $(embedFile =<< makeRelativeToProject "email.css") 283 | Just styleHtml -> styleHtml 284 | H.title titleHtml 285 | H.body $ H.table $ do 286 | H.tr $ H.td $ contentHtml 287 | H.tr $ H.td $ H.table $ H.tr $ H.td $ do 288 | H.hr 289 | H.p $ do 290 | H.text "Brought to you by " 291 | H.a ! A.href (fromString $ show indexLink) $ H.toHtml productName 292 | -------------------------------------------------------------------------------- /email/src/Rhyolite/Email/Orphans.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | {-# LANGUAGE StandaloneDeriving #-} 3 | {-# OPTIONS_GHC -Wno-orphans #-} 4 | 5 | module Rhyolite.Email.Orphans where 6 | 7 | import ByteString.Aeson.Orphans () 8 | import Data.Aeson 9 | import Data.Aeson.TH 10 | import qualified Network.Mail.Mime as Mail 11 | 12 | deriveJSON defaultOptions ''Mail.Address 13 | deriveJSON defaultOptions ''Mail.Disposition 14 | deriveJSON defaultOptions ''Mail.Encoding 15 | deriveJSON defaultOptions ''Mail.Part 16 | deriveJSON defaultOptions ''Mail.PartContent 17 | deriveJSON defaultOptions ''Mail.Mail 18 | 19 | deriving instance Read Mail.Address 20 | -------------------------------------------------------------------------------- /frontend/Rhyolite/Frontend/Auth.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ScopedTypeVariables #-} 2 | {-# LANGUAGE RankNTypes #-} 3 | {-# LANGUAGE TypeFamilies #-} 4 | {-# LANGUAGE LambdaCase #-} 5 | 6 | module Rhyolite.Frontend.Auth where 7 | 8 | import Control.Category 9 | import Control.Monad.Fix 10 | import Data.Functor.Const 11 | import Prelude hiding ((.), id) 12 | import Reflex 13 | 14 | import Rhyolite.Api 15 | import Rhyolite.Frontend.App 16 | import Rhyolite.Vessel.AuthMapV 17 | import Rhyolite.Vessel.AuthenticatedV 18 | import Rhyolite.Vessel.Types 19 | 20 | -- | The type for app widgets at the top level of the app. 21 | -- Includes full Auth handling for queries and requests. 22 | type FullWidget token publicApi privateApi publicV privateV personalV = 23 | RhyoliteWidget 24 | (FullV token publicV privateV personalV (Const SelectedCount)) 25 | (ApiRequest token publicApi privateApi) 26 | 27 | type FullWidgetInternal token publicApi privateApi publicV privateV personalV t m = 28 | RhyoliteWidgetInternal 29 | (FullV token publicV privateV personalV (Const SelectedCount)) 30 | (ApiRequest token publicApi privateApi) 31 | t m 32 | 33 | -- | The type for app widgets that have been specialized to a particular 34 | -- authenticated identity that may or may not be valid, and so the queries 35 | -- can fail. 36 | type AuthErrorWidget token publicApi privateApi publicV privateV personalV = 37 | RhyoliteWidget 38 | (FullAuthErrorV publicV privateV personalV (Const SelectedCount)) 39 | (ApiRequest () publicApi privateApi) 40 | 41 | type AuthErrorWidgetInternal token publicApi privateApi publicV privateV personalV t m = 42 | RhyoliteWidgetInternal 43 | (FullAuthErrorV publicV privateV personalV (Const SelectedCount)) 44 | (ApiRequest () publicApi privateApi) 45 | t m 46 | 47 | -- | The type for app widgets that have been specialized to a particular 48 | -- authenticated identity where authentication errors have already been handled 49 | -- so the queries cannot fail within this widget. 50 | type AuthWidget token publicApi privateApi publicV privateV personalV = 51 | RhyoliteWidget 52 | (AuthenticatedV publicV privateV personalV (Const SelectedCount)) 53 | (ApiRequest () publicApi privateApi ) 54 | 55 | type AuthWidgetInternal token publicApi privateApi publicV privateV personalV t m = 56 | RhyoliteWidgetInternal 57 | (AuthenticatedV publicV privateV personalV (Const SelectedCount)) 58 | (ApiRequest () publicApi privateApi ) 59 | t m 60 | 61 | -- | Embeds a widget that uses a specific auth identity into a context where no auth identity is presumed. 62 | authenticatedWidget 63 | :: ( MonadFix m, PostBuild t m 64 | , HasRhyoliteAuth token publicV privateV personalV ) 65 | => token 66 | -> AuthErrorWidget token publicApi privateApi publicV privateV personalV t m a 67 | -> FullWidget token publicApi privateApi publicV privateV personalV t m a 68 | authenticatedWidget token = mapAuth token (mapAuthenticatedV id (authMapQueryMorphism token) (authMapQueryMorphism token)) . unRhyoliteWidget 69 | -------------------------------------------------------------------------------- /frontend/Rhyolite/Frontend/Auth/App.hs: -------------------------------------------------------------------------------- 1 | module Rhyolite.Frontend.Auth.App 2 | ( module Rhyolite.Frontend.Auth.App 3 | , module X 4 | ) where 5 | 6 | import qualified Rhyolite.Frontend.Auth as Base 7 | import Rhyolite.Vessel.App as X 8 | import Control.Monad.Fix 9 | import Reflex 10 | 11 | 12 | -- | The type for app widgets at the top level of the app. 13 | -- Includes full Auth handling for queries and requests. 14 | type FullAppWidget app = 15 | Base.FullWidget (AuthCredential app) (PublicApi app) (PrivateApi app) (PublicV app) (PrivateV app) (PersonalV app) 16 | 17 | -- | The type for app widgets that have been specialized to a particular 18 | -- authenticated identity that may or may not be valid, and so the queries 19 | -- can fail. 20 | type AuthErrorAppWidget app = 21 | Base.AuthErrorWidget (AuthCredential app) (PublicApi app) (PrivateApi app) (PublicV app) (PrivateV app) (PersonalV app) 22 | 23 | -- | The type for app widgets that have been specialized to a particular 24 | -- authenticated identity where authentication errors have already been handled 25 | -- so the queries cannot fail within this widget. 26 | type AuthAppWidget app = 27 | Base.AuthWidget (AuthCredential app) (PublicApi app) (PrivateApi app) (PublicV app) (PrivateV app) (PersonalV app) 28 | 29 | -- | Embeds a widget that uses a specific auth identity into a context where no auth identity is presumed. 30 | authenticatedWidget 31 | :: ( MonadFix m, PostBuild t m 32 | , RhyoliteAuthApp app ) 33 | => proxy app 34 | -> AuthCredential app 35 | -> AuthErrorAppWidget app t m a 36 | -> FullAppWidget app t m a 37 | authenticatedWidget _ = Base.authenticatedWidget 38 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/Rhyolite/Frontend/Cookie.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: 3 | Getting and setting cookies 4 | 5 | Getting and setting cookies on the frontend. Cookies are base64 encoded. 6 | 7 | There's some overlap between the functions in this module and 8 | Obelisk.Frontend.Cookie. That module provides 'askCookies', which can also be 9 | used to retrieve cookies. The Obelisk module has the advantage of working 10 | server-side as well, so that widgets that depend on the cookie can be 11 | prerendered. The functions in this module use javascript and, so, cannot be 12 | rendered server-side. If you're mixing the two, bear in mind that obelisk does 13 | not currently demand that cookies be base64-encoded, while this module does, 14 | so you'll have to base64-decode the result of askCookies yourself. 15 | -} 16 | {-# Language OverloadedStrings #-} 17 | {-# Language ScopedTypeVariables #-} 18 | {-# Language FlexibleContexts #-} 19 | module Rhyolite.Frontend.Cookie where 20 | 21 | import Control.Monad ((<=<)) 22 | import Data.Aeson as Aeson 23 | import Data.ByteString (ByteString) 24 | import qualified Data.ByteString.Base64 as B64 25 | import Data.ByteString.Builder (toLazyByteString) 26 | import qualified Data.ByteString.Lazy as LBS 27 | import Data.Either.Combinators 28 | import Data.Text (Text) 29 | import Data.Text.Encoding 30 | import Data.Time.Calendar 31 | import Data.Time.Clock 32 | import Data.Time.Clock.POSIX 33 | import qualified GHCJS.DOM.Document as DOM 34 | import GHCJS.DOM.Types (MonadJSM) 35 | import Reflex.Dom.Core 36 | import Web.Cookie 37 | 38 | -- | "To maximize compatibility with user agents, servers that wish to 39 | -- store arbitrary data in a cookie-value SHOULD encode that data, for 40 | -- example, using Base64" 41 | -- 42 | base64EncodeCookie 43 | :: SetCookie 44 | -> SetCookie 45 | base64EncodeCookie c = c { setCookieValue = B64.encode (setCookieValue c) } 46 | 47 | -- | A synonym for Data.ByteString.Base64.decode because there are too many 48 | -- functions called "decode". 49 | base64Decode :: ByteString -> Either String ByteString 50 | base64Decode = B64.decode 51 | 52 | -- | Set or clear the given cookie permanently 53 | -- 54 | -- Example: 55 | -- > setPermanentCookie doc =<< defaultCookie "key" (Just "value") 56 | setPermanentCookie :: (MonadJSM m) => DOM.Document -> SetCookie -> m () 57 | setPermanentCookie doc cookie = do 58 | DOM.setCookie doc $ decodeUtf8 $ LBS.toStrict $ toLazyByteString $ renderSetCookie $ base64EncodeCookie cookie 59 | 60 | -- | Set or clear the given cookie with given expiration date 61 | -- 62 | -- Example: 63 | -- > setExpiringCookie time doc =<< defaultCookie "key" (Just "value") 64 | setExpiringCookie :: (MonadJSM m) => UTCTime -> DOM.Document -> SetCookie -> m () 65 | setExpiringCookie timestamp doc cookie = do 66 | DOM.setCookie doc $ decodeUtf8 $ LBS.toStrict $ toLazyByteString $ renderSetCookie $ base64EncodeCookie cookie {setCookieExpires = Just timestamp} 67 | 68 | -- | Make a cookie with sensible defaults 69 | defaultCookie 70 | :: (MonadJSM m) 71 | => Text -- ^ Cookie key 72 | -> Maybe Text -- ^ Cookie value (Nothing clears it) 73 | -> m SetCookie 74 | defaultCookie key mv = do 75 | currentProtocol <- Reflex.Dom.Core.getLocationProtocol 76 | pure $ case mv of 77 | Nothing -> def 78 | { setCookieName = encodeUtf8 key 79 | , setCookieValue = "" 80 | , setCookieExpires = Just $ posixSecondsToUTCTime 0 81 | } 82 | Just val -> def 83 | { setCookieName = encodeUtf8 key 84 | , setCookieValue = encodeUtf8 val 85 | -- We don't want these to expire, but browsers don't support 86 | -- non-expiring cookies. Some systems have trouble representing dates 87 | -- past 2038, so use 2037. 88 | , setCookieExpires = Just $ UTCTime (fromGregorian 2037 1 1) 0 89 | , setCookieSecure = currentProtocol == "https:" 90 | -- This helps prevent CSRF attacks; we don't want strict, because it 91 | -- would prevent links to the page from working; lax is secure enough, 92 | -- because we don't take dangerous actions simply by executing a GET 93 | -- request. 94 | , setCookieSameSite = if currentProtocol == "file:" 95 | then Nothing 96 | else Just sameSiteLax 97 | } 98 | 99 | -- | JSON encode some data and set it as a cookie 100 | defaultCookieJson :: (MonadJSM m, ToJSON v) => Text -> Maybe v -> m SetCookie 101 | defaultCookieJson k = defaultCookie k . fmap (decodeUtf8 . LBS.toStrict . encode) 102 | 103 | -- | Set a cookie with the given domain location: see 104 | -- 105 | -- for @;domain=domain@ 106 | setPermanentCookieWithLocation :: (MonadJSM m) => DOM.Document -> Maybe ByteString -> Text -> Maybe Text -> m () 107 | setPermanentCookieWithLocation doc loc key mv = do 108 | cookie <- defaultCookie key mv 109 | setPermanentCookie doc $ cookie { setCookieDomain = loc } 110 | 111 | data GetCookieFailed 112 | = GetCookieFailed_NotFound 113 | | GetCookieFailed_Base64DecodeFailed String 114 | 115 | -- | Retrieve the value of the given cookie 116 | getCookie :: MonadJSM m => DOM.Document -> Text -> m (Either GetCookieFailed Text) 117 | getCookie doc key = do 118 | cookieString <- encodeUtf8 <$> DOM.getCookie doc 119 | pure $ case lookup (encodeUtf8 key) $ parseCookies cookieString of 120 | Nothing -> Left GetCookieFailed_NotFound 121 | Just c -> mapBoth GetCookieFailed_Base64DecodeFailed decodeUtf8 $ 122 | base64Decode c 123 | 124 | -- | JSON encode some data and set it as a permanent cookie 125 | setPermanentCookieJson :: (MonadJSM m, ToJSON v) => DOM.Document -> Text -> Maybe v -> m () 126 | setPermanentCookieJson d k = setPermanentCookie d <=< defaultCookieJson k 127 | 128 | data GetCookieJsonFailed 129 | = GetCookieJsonFailed_GetCookieFailed GetCookieFailed 130 | | GetCookieJsonFailed_ParseFailure String 131 | 132 | -- | Read a cookie. You may want to use 'Obelisk.Frontend.Cookie.askCookies' 133 | -- along with 'base64Decode' instead. 134 | getCookieJson :: (FromJSON v, MonadJSM m) => DOM.Document -> Text -> m (Either GetCookieJsonFailed v) 135 | getCookieJson d k = do 136 | r <- fmap (eitherDecode . LBS.fromStrict . encodeUtf8) <$> getCookie d k 137 | pure $ case r of 138 | Left failure -> Left $ GetCookieJsonFailed_GetCookieFailed failure 139 | Right (Left parseFailure) -> Left $ GetCookieJsonFailed_ParseFailure parseFailure 140 | Right (Right v) -> Right v 141 | 142 | -- | Get a cookie and run an action on it. Set the cookie value to the result 143 | -- of the action. 144 | withPermanentCookieJson :: 145 | ( MonadJSM m 146 | , MonadJSM (Performable m) 147 | , PerformEvent t m 148 | , ToJSON v 149 | , FromJSON v 150 | ) 151 | => DOM.Document 152 | -> Text 153 | -> (Either GetCookieJsonFailed v -> m (Event t (Maybe v))) 154 | -> m () 155 | withPermanentCookieJson d k a = do 156 | cookie0 <- getCookieJson d k 157 | cookieE <- a cookie0 158 | performEvent_ $ setPermanentCookieJson d k <$> cookieE 159 | return () 160 | -------------------------------------------------------------------------------- /frontend/rhyolite-frontend.cabal: -------------------------------------------------------------------------------- 1 | name: rhyolite-frontend 2 | version: 0.1 3 | license: BSD3 4 | copyright: Copyright (C) Obsidian Systems LLC 2018 5 | author: Obsidian Systems LLC 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | build-type: Simple 9 | cabal-version: >=1.2 10 | synopsis: Build apps that use a rhyolite backend as a datasource 11 | description: 12 | Rhyolite frontend apps are typically obelisk- and reflex-based applications that need live-updating data from the backend. This package provides the adapters necessary to work with requests and streaming data from a rhyolite backend. 13 | 14 | library 15 | hs-source-dirs: . 16 | 17 | if impl(ghcjs -any) 18 | build-depends: ghcjs-base 19 | 20 | build-depends: 21 | aeson 22 | , base 23 | , bytestring 24 | , base64-bytestring 25 | , commutative-semigroups 26 | , constraints 27 | , constraints-extras 28 | , containers 29 | , cookie 30 | , data-default 31 | , either 32 | , exception-transformers 33 | , ghcjs-dom 34 | , jsaddle 35 | , lens 36 | , mtl 37 | , network-uri 38 | , obelisk-executable-config-lookup 39 | , obelisk-frontend 40 | , obelisk-route 41 | , primitive 42 | , ref-tf 43 | , reflex 44 | , reflex-dom-core 45 | , rhyolite-common 46 | , some 47 | , text 48 | , time 49 | , transformers 50 | , validation 51 | , vessel 52 | , witherable 53 | 54 | exposed-modules: 55 | Rhyolite.Frontend.App 56 | Rhyolite.Frontend.Cookie 57 | Rhyolite.Frontend.Auth 58 | Rhyolite.Frontend.Auth.App 59 | 60 | ghc-options: 61 | -Wall -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields 62 | -O2 -fprof-auto-calls -fexpose-all-unfoldings 63 | -------------------------------------------------------------------------------- /gen-docs.sh: -------------------------------------------------------------------------------- 1 | pkgdb=$(ghc-pkg list | head -1) 2 | rev=$(git rev-parse HEAD | cut -c1-8) 3 | tmpdir=$(mktemp -d -t rhyo-docs-$rev-XXXXXX) 4 | 5 | function cleanup { 6 | rm -rf "$tmpdir" 7 | } 8 | 9 | trap cleanup EXIT 10 | 11 | standalone-haddock \ 12 | --package-db "$pkgdb" \ 13 | --dist-dir "$tmpdir" \ 14 | -o rhyolite-docs-$rev \ 15 | backend \ 16 | common \ 17 | email \ 18 | frontend \ 19 | notify-listen/notify-listen/ \ 20 | notify-listen/notify-listen-beam/ \ 21 | psql-extras/psql-serializable/ \ 22 | psql-extras/psql-simple-beam/ \ 23 | psql-extras/psql-simple-class/ \ 24 | semimap/ \ 25 | signed-data/signed-data/ \ 26 | signed-data/signed-data-clientsession/ \ 27 | widgets/ \ 28 | account/types \ 29 | account/backend 30 | # groundhog-legacy/groundhog-legacy \ 31 | # groundhog-legacy/groundhog-legacy-types/ \ 32 | # psql-extras/psql-simple-groundhog/ \ 33 | 34 | 35 | -------------------------------------------------------------------------------- /notify-listen/notify-listen-beam/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for rhyolite-notify-listen-beam 2 | 3 | ## Unreleased 4 | 5 | * Add some new combinators for working with an {insert, update, delete} statement that returns arbitrary data 6 | 7 | * Breaking changes: 8 | * Change the type of deleteAndNotify to account for deletions that can fail. 9 | 10 | ## 0.1.0.0 11 | 12 | * First version. Released on an unsuspecting world. 13 | -------------------------------------------------------------------------------- /notify-listen/notify-listen-beam/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /notify-listen/notify-listen-beam/rhyolite-notify-listen-beam.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-notify-listen-beam 3 | version: 0.1.0.0 4 | synopsis: 5 | 6 | -- A longer description of the package. 7 | -- description: 8 | homepage: 9 | 10 | -- A URL where users can report bugs. 11 | -- bug-reports: 12 | license: BSD-3-Clause 13 | license-file: LICENSE 14 | author: Obsidian Systems LLC 15 | maintainer: maintainer@obsidian.systems 16 | 17 | -- A copyright notice. 18 | -- copyright: 19 | category: Database 20 | extra-source-files: CHANGELOG.md 21 | 22 | library 23 | exposed-modules: Rhyolite.DB.NotifyListen.Beam 24 | build-depends: 25 | , aeson 26 | , base 27 | , beam-core 28 | , beam-postgres 29 | , constraints 30 | , constraints-extras 31 | , dependent-sum 32 | , psql-simple-beam 33 | , psql-simple-class 34 | , rhyolite-common 35 | , rhyolite-notify-listen 36 | , these 37 | 38 | hs-source-dirs: src 39 | default-language: Haskell2010 40 | -------------------------------------------------------------------------------- /notify-listen/notify-listen/rhyolite-notify-listen.cabal: -------------------------------------------------------------------------------- 1 | name: rhyolite-notify-listen 2 | version: 0.1.0.0 3 | license: BSD3 4 | copyright: Copyright (C) Obsidian Systems LLC 2021 5 | author: Obsidian Systems LLC 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | build-type: Simple 9 | cabal-version: >=1.2 10 | description: Notification mechanism using postgres. 11 | 12 | library 13 | hs-source-dirs: src 14 | build-depends: 15 | aeson 16 | , base 17 | , bytestring 18 | , constraints 19 | , constraints-extras 20 | , dependent-sum 21 | , dependent-sum-aeson-orphans 22 | , postgresql-simple 23 | , psql-simple-class 24 | , resource-pool 25 | , split 26 | , stm 27 | , text 28 | 29 | exposed-modules: Rhyolite.DB.NotifyListen 30 | ghc-options: 31 | -Wall -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields 32 | -O2 -fprof-auto-calls 33 | -------------------------------------------------------------------------------- /notify-listen/notify-listen/src/Rhyolite/DB/NotifyListen.hs: -------------------------------------------------------------------------------- 1 | {-| 2 | Description: DB notifications 3 | 4 | Use the postgres NOTIFY/LISTEN commands to distribute information about updates 5 | to the database. 6 | -} 7 | {-# Language DeriveGeneric #-} 8 | {-# Language FlexibleContexts #-} 9 | {-# Language GADTs #-} 10 | {-# Language GeneralizedNewtypeDeriving #-} 11 | {-# Language OverloadedStrings #-} 12 | {-# Language ScopedTypeVariables #-} 13 | {-# Language StandaloneDeriving #-} 14 | {-# Language UndecidableInstances #-} 15 | 16 | module Rhyolite.DB.NotifyListen 17 | ( -- * Sending notifications 18 | notify 19 | , notify' 20 | , NotificationType(..) 21 | , DbNotification(..) 22 | -- * Running a notification listener 23 | , notificationListener 24 | , startNotificationListener 25 | , NotificationChannel(..) 26 | , defaultNotificationChannel 27 | -- * Helpers 28 | , listenCmd 29 | , notifyCmd 30 | , SchemaName(..) 31 | , getSchemaName 32 | , getSearchPath 33 | ) where 34 | 35 | import Control.Concurrent (forkIO, killThread) 36 | import Control.Concurrent.STM (TChan) 37 | import qualified Control.Concurrent.STM as STM 38 | import Control.Monad (forever) 39 | import Data.Aeson (FromJSON, ToJSON, encode, decodeStrict') 40 | import qualified Data.ByteString as BS 41 | import qualified Data.ByteString.Lazy as LBS 42 | import Data.Constraint.Extras 43 | import Data.Constraint.Forall 44 | import Data.Dependent.Sum (DSum(..)) 45 | import Data.Dependent.Sum.Orphans () 46 | import Data.Functor.Identity (Identity(..)) 47 | import Data.List.Split (wordsBy) 48 | import Data.Maybe (listToMaybe) 49 | import Data.Pool (Pool, withResource) 50 | import Data.Some (Some) 51 | import Data.String (fromString) 52 | import Data.Text (Text) 53 | import qualified Data.Text as T 54 | import qualified Data.Text.Encoding as T 55 | import Data.Typeable 56 | import qualified Database.PostgreSQL.Simple as PG 57 | import Database.PostgreSQL.Simple.Class 58 | import qualified Database.PostgreSQL.Simple.Notification as PG 59 | import GHC.Generics (Generic) 60 | 61 | -- | Wrapper for the name of the channel over which notifications are sent 62 | newtype NotificationChannel 63 | = NotificationChannel { unNotificationChannel :: String } 64 | 65 | -- | The channel name (@"updates"@) used for insert/update/delete 66 | -- notifications. 67 | defaultNotificationChannel :: NotificationChannel 68 | defaultNotificationChannel = NotificationChannel "updates" 69 | 70 | -- | Constructs a @LISTEN@ command with the given channel name 71 | listenCmd :: NotificationChannel -> PG.Query 72 | listenCmd (NotificationChannel chan) = fromString $ "LISTEN " <> chan 73 | 74 | -- | Starts a thread to receive messages about changes to the database via the 75 | -- postgres @LISTEN@ mechanism. 76 | notificationListener 77 | :: (FromJSON notifyMessage) 78 | => Pool PG.Connection 79 | -- ^ Connection pool 80 | -> IO (TChan notifyMessage, IO ()) 81 | -- ^ @notifyMessage@ is usually a 'DbNotification' 82 | notificationListener db = do 83 | nChan <- STM.newBroadcastTChanIO 84 | daemonThread <- forkIO $ withResource db $ \conn -> do 85 | let cmd = listenCmd defaultNotificationChannel 86 | _ <- PG.execute_ conn cmd 87 | forever $ do 88 | -- Handle notifications 89 | PG.Notification _ channel message <- PG.getNotification conn 90 | case channel of 91 | _ | channel == channelToByteString defaultNotificationChannel -> do 92 | -- Notification is on the expected NOTIFY channel 93 | case decodeStrict' message of 94 | Just a -> STM.atomically $ STM.writeTChan nChan a 95 | _ -> putStrLn $ errorMessage defaultNotificationChannel $ 96 | "Could not parse message: " <> show message 97 | _ -> putStrLn $ errorMessage defaultNotificationChannel $ 98 | "Received a message on unexpected channel: " <> show channel 99 | return (nChan, killThread daemonThread) 100 | where 101 | channelToByteString :: NotificationChannel -> BS.ByteString 102 | channelToByteString = T.encodeUtf8 . T.pack . unNotificationChannel 103 | errorMessage :: NotificationChannel -> String -> String 104 | errorMessage (NotificationChannel c) err = 105 | "notificationListener: channel \"" <> c <> "\": " <> err 106 | 107 | -- | Starts a thread that listens for updates to the db and returns a 108 | -- 'DbNotification' retrieval function and finalizer 109 | startNotificationListener 110 | :: FromJSON notifyMessage 111 | => Pool PG.Connection 112 | -> IO (IO notifyMessage, IO ()) 113 | startNotificationListener pool = do 114 | (chan, nkill) <- notificationListener pool 115 | chan' <- STM.atomically $ STM.dupTChan chan 116 | return (STM.atomically $ STM.readTChan chan', nkill) 117 | 118 | -- | Get the schema name out of the current @search_path@ 119 | getSchemaName :: Psql m 120 | => m String 121 | getSchemaName = do 122 | searchPath <- getSearchPath 123 | let searchPathComponents = wordsBy (==',') searchPath 124 | schemaName = case searchPathComponents of 125 | (x:_:_:_) -> x 126 | _ -> "public" 127 | return schemaName 128 | 129 | -- | Get the current @search_path@ 130 | getSearchPath :: Psql m => m String 131 | getSearchPath = do 132 | rows <- query_ "SHOW search_path" 133 | case listToMaybe rows of 134 | Nothing -> error "getSearchPath: Unexpected result from queryRaw" 135 | Just (PG.Only searchPath) -> return searchPath 136 | 137 | -- | Used to indicate whether a given notification is an insert, update, or 138 | -- deletion. Deletions, for instance, may require special handling, as the 139 | -- deleted data will no longer be in the database. 140 | data NotificationType 141 | = NotificationType_Insert 142 | | NotificationType_Update 143 | | NotificationType_Delete 144 | deriving (Show, Read, Eq, Ord, Enum, Bounded, Generic) 145 | 146 | instance ToJSON NotificationType 147 | instance FromJSON NotificationType 148 | 149 | -- | A notification payload. 150 | data DbNotification n = DbNotification 151 | { _dbNotification_schemaName :: SchemaName 152 | -- ^ The schema that was updated 153 | , _dbNotification_notificationType :: NotificationType 154 | -- ^ Whether this is an insert, update, or delete 155 | , _dbNotification_message :: DSum n Identity 156 | -- ^ The notification message itself. The notification protocol is defined as 157 | -- a GADT. See 'notify'. 158 | } deriving (Generic) 159 | 160 | deriving instance (Show (DSum n Identity)) => Show (DbNotification n) 161 | 162 | instance 163 | ( Has' ToJSON n Identity 164 | , ForallF ToJSON n 165 | ) 166 | => ToJSON (DbNotification n) 167 | 168 | instance 169 | ( Has' FromJSON n Identity 170 | , FromJSON (Some n) 171 | ) => FromJSON (DbNotification n) 172 | 173 | -- | Constructs a @NOTIFY@ command with the given channel name 174 | notifyCmd :: NotificationChannel -> PG.Query 175 | notifyCmd (NotificationChannel chan) = fromString $ "NOTIFY " <> chan <> ", ?" 176 | 177 | -- | Sends a notification over a given channel. Notification payloads must have 178 | -- a JSON representation for transmission. 179 | -- 180 | -- The notification message itself is wrapped in a 'DSum' to allow messages of 181 | -- different types. To use this, construct a GADT that defines your 182 | -- notification protocol: 183 | -- 184 | -- > data Notify a where 185 | -- > Notify_Account :: Notify (Id Account) 186 | -- > Notify_Chatroom :: Notify (Id Chatroom) 187 | -- > Notify_Message :: Notify (Id Message) 188 | -- 189 | -- Now, you can use the GADT constructor that corresponds to the particular 190 | -- notification payload type you'd like to send. For example, here's an 191 | -- invocation that sends a notification that a new account has been created: 192 | -- 193 | -- > notify myChan conn NotificationType_Insert Notify_Account accountId 194 | -- 195 | -- NB: The maximum payload size is 8000 bytes (this is currently neither 196 | -- checked nor enforced in this library): 197 | -- 198 | -- > The "payload" string to be communicated along with the notification. This 199 | -- > must be specified as a simple string literal. In the default configuration 200 | -- > it must be shorter than 8000 bytes. (If binary data or large amounts of 201 | -- > information need to be communicated, it's best to put it in a database table 202 | -- > and send the key of the record.) 203 | -- (Source: https://www.postgresql.org/docs/9.0/sql-notify.html) 204 | notify 205 | :: ( Has' ToJSON notice Identity 206 | , ForallF ToJSON notice 207 | , Psql m 208 | ) 209 | => NotificationType 210 | -> notice a 211 | -> a 212 | -> m () 213 | notify nt n a = notify' nt (n :=> Identity a) 214 | 215 | -- | Variation of 'notify' which takes the notification tag and payload already combined 216 | -- as a 'DSum' 217 | notify' 218 | :: ( Has' ToJSON notice Identity 219 | , ForallF ToJSON notice 220 | , Psql m 221 | ) 222 | => NotificationType 223 | -> DSum notice Identity 224 | -> m () 225 | notify' nt na = do 226 | schemaName <- getSchemaName 227 | let cmd = notifyCmd defaultNotificationChannel 228 | notifyMsg = DbNotification 229 | { _dbNotification_schemaName = SchemaName $ T.pack schemaName 230 | , _dbNotification_notificationType = nt 231 | , _dbNotification_message = na 232 | } 233 | _ <- execute cmd 234 | [ T.unpack $ T.decodeUtf8 $ LBS.toStrict $ encode notifyMsg 235 | ] 236 | return () 237 | 238 | -- | Wrapper for database schema names 239 | newtype SchemaName = SchemaName { unSchemaName :: Text } 240 | deriving (Eq, Ord, Read, Show, FromJSON, ToJSON, Typeable, Generic) 241 | -------------------------------------------------------------------------------- /psql-extras/psql-serializable/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /psql-extras/psql-serializable/psql-serializable.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: psql-serializable 3 | version: 0.1.0.0 4 | synopsis: A monad for transactions with serializable isolation level 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Database 13 | 14 | library 15 | exposed-modules: Database.PostgreSQL.Serializable 16 | build-depends: 17 | , base 18 | , bytestring 19 | , exceptions 20 | , monad-logger 21 | , monad-logger-extras 22 | , mtl 23 | , postgresql-simple 24 | , psql-simple-class 25 | , resource-pool 26 | , transformers 27 | , transformers-base 28 | 29 | hs-source-dirs: src 30 | default-language: Haskell2010 31 | ghc-options: -Wall 32 | -------------------------------------------------------------------------------- /psql-extras/psql-serializable/src/Database/PostgreSQL/Serializable.hs: -------------------------------------------------------------------------------- 1 | {-# Language FlexibleInstances #-} 2 | {-# Language GeneralizedNewtypeDeriving #-} 3 | {-# Language MultiParamTypeClasses #-} 4 | {-# Language QuantifiedConstraints #-} 5 | {-# Language ScopedTypeVariables #-} 6 | {-# Language RankNTypes #-} 7 | module Database.PostgreSQL.Serializable 8 | ( Serializable 9 | , unsafeMkSerializable 10 | , unSerializable 11 | , runSerializable 12 | , runSerializableInsideTransaction 13 | , unsafeHoistCoerceFromReaderT 14 | ) where 15 | 16 | import Control.Monad.Base (MonadBase (liftBase)) 17 | import Control.Monad.Catch (MonadThrow) 18 | import Control.Monad.IO.Class 19 | import Control.Monad.Logger 20 | import Control.Monad.Logger.Extras 21 | import Control.Monad.Trans.Reader 22 | import Data.Coerce 23 | import Data.Pool 24 | import qualified Database.PostgreSQL.Simple as Pg 25 | import Database.PostgreSQL.Simple.Class 26 | import qualified Database.PostgreSQL.Simple.Transaction as Pg 27 | 28 | -- | A monad for database transactions with serializable isolation level. 29 | -- 30 | -- Because this monad may retry execution of code automatically, it does not lawfully lift any effects other 31 | -- than Reader, which it commutes with, so there is no gain in having it be a transformer. 32 | -- 33 | -- It "disallows" (makes harder) arbitrary IO. 34 | -- It "disallows" (makes harder) catching IO exceptions *inside* the transaction. 35 | newtype Serializable a = Serializable (ReaderT Pg.Connection (LoggingT IO) a) 36 | deriving (Functor, Applicative, Monad, MonadThrow, MonadLogger) 37 | -- NOTE: We *intentionally* leave out 38 | -- - 'MonadCatch' so you can't accidentally mask a serialization error from the outer retry logic. 39 | -- - 'MonadBaseControl' (et al) for the same reason. 40 | -- - 'MonadIO' so you can't execute arbitrary IO. 41 | 42 | instance MonadBase Serializable Serializable where 43 | liftBase = id 44 | 45 | unsafeMkSerializable :: ReaderT Pg.Connection (LoggingT IO) a -> Serializable a 46 | unsafeMkSerializable = Serializable 47 | 48 | unSerializable :: Serializable a -> ReaderT Pg.Connection (LoggingT IO) a 49 | unSerializable (Serializable m) = m 50 | 51 | runSerializable :: forall a m. (MonadIO m) => Pool Pg.Connection -> Logger -> Serializable a -> m a 52 | runSerializable pool logger (Serializable act) = liftIO $ withResource pool $ \c -> 53 | Pg.withTransactionSerializable c $ 54 | runLoggerLoggingT (runReaderT act c) logger 55 | 56 | -- | Run a @Serializable@ inside a Postgres Transaction. Note that this function does not create a transaction on 57 | -- its own, that responsibility lies with the function that invokes it. 58 | runSerializableInsideTransaction :: forall a m. (MonadIO m) => Pg.Connection -> Logger -> Serializable a -> m a 59 | runSerializableInsideTransaction conn logger (Serializable act) = liftIO $ runLoggerLoggingT (runReaderT act conn) logger 60 | 61 | unsafeHoistCoerceFromReaderT :: forall f. (forall a b. Coercible a b => Coercible (f a) (f b)) => f (ReaderT Pg.Connection (LoggingT IO)) -> f Serializable 62 | unsafeHoistCoerceFromReaderT = coerce 63 | 64 | instance Psql Serializable where 65 | askConn = Serializable ask 66 | execute p q = Serializable $ do 67 | conn <- ask 68 | liftIO $ Pg.execute conn p q 69 | execute_ q = Serializable $ ask >>= \conn -> liftIO $ Pg.execute_ conn q 70 | executeMany p q = Serializable $ ask >>= \conn -> liftIO $ Pg.executeMany conn p q 71 | query p q = Serializable $ ask >>= \conn -> liftIO $ Pg.query conn p q 72 | query_ q = Serializable $ ask >>= \conn -> liftIO $ Pg.query_ conn q 73 | queryWith parser p q = Serializable $ ask >>= \conn -> liftIO $ Pg.queryWith parser conn p q 74 | queryWith_ parser q = Serializable $ ask >>= \conn -> liftIO $ Pg.queryWith_ parser conn q 75 | formatQuery p q = Serializable $ ask >>= \conn -> liftIO $ Pg.formatQuery conn p q 76 | returning p q = Serializable $ ask >>= \conn -> liftIO $ Pg.returning conn p q 77 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-beam/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for psql-simple-beam 2 | 3 | ## 0.1.0.0 4 | 5 | * Create Psql instance for beam's `Pg` 6 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-beam/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-beam/psql-simple-beam.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: psql-simple-beam 3 | version: 0.1.0.0 4 | synopsis: A beam instance for Psql 5 | homepage: https://github.com/obsidiansystems/rhyolite 6 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 7 | license: BSD-3-Clause 8 | license-file: LICENSE 9 | author: Obsidian Systems LLC 10 | maintainer: maintainer@obsidian.systems 11 | copyright: 2021 Obsidian Systems LLC 12 | category: Database 13 | extra-source-files: CHANGELOG.md 14 | 15 | library 16 | exposed-modules: Database.PostgreSQL.Simple.Beam 17 | build-depends: 18 | , base 19 | , beam-core 20 | , beam-postgres 21 | , postgresql-simple 22 | , psql-simple-class 23 | 24 | hs-source-dirs: src 25 | default-language: Haskell2010 26 | ghc-options: -Wall 27 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-beam/src/Database/PostgreSQL/Simple/Beam.hs: -------------------------------------------------------------------------------- 1 | {-# options_ghc -fno-warn-orphans #-} 2 | module Database.PostgreSQL.Simple.Beam where 3 | 4 | import Database.PostgreSQL.Simple.Class 5 | import qualified Database.PostgreSQL.Simple as P 6 | import Database.Beam.Postgres 7 | 8 | instance Psql Pg where 9 | askConn = liftIOWithHandle pure 10 | execute a b = liftIOWithHandle $ \c -> P.execute c a b 11 | execute_ a = liftIOWithHandle $ \c -> P.execute_ c a 12 | executeMany a b = liftIOWithHandle $ \c -> P.executeMany c a b 13 | query a b = liftIOWithHandle $ \c -> P.query c a b 14 | query_ a = liftIOWithHandle $ \c -> P.query_ c a 15 | queryWith foo bar baz = liftIOWithHandle $ \c -> P.queryWith foo c bar baz 16 | queryWith_ foo bar = liftIOWithHandle $ \c -> P.queryWith_ foo c bar 17 | formatQuery a b = liftIOWithHandle $ \c -> P.formatQuery c a b 18 | returning a b = liftIOWithHandle $ \c -> P.returning c a b 19 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-class/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for psql-simple-class 2 | 3 | ## 0.1.0.0 4 | 5 | * Create class Psql with various instances 6 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-class/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /psql-extras/psql-simple-class/psql-simple-class.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: psql-simple-class 3 | version: 0.1.0.0 4 | synopsis: A typeclass for postgresql-simple 5 | description: 6 | Typeclass and instances for database queries using postgresql-simple 7 | 8 | homepage: https://github.com/obsidiansystems/rhyolite 9 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 10 | license: BSD-3-Clause 11 | license-file: LICENSE 12 | author: Obsidian Systems LLC 13 | maintainer: maintainer@obsidian.systems 14 | copyright: 2021 Obsidian Systems LLC 15 | category: Database 16 | extra-source-files: CHANGELOG.md 17 | 18 | library 19 | exposed-modules: Database.PostgreSQL.Simple.Class 20 | build-depends: 21 | , base 22 | , bytestring 23 | , io-streams 24 | , mtl 25 | , postgresql-lo-stream 26 | , postgresql-simple 27 | , postgresql-simple-interpolate 28 | , template-haskell 29 | , transformers 30 | 31 | hs-source-dirs: src 32 | default-language: Haskell2010 33 | ghc-options: -Wall 34 | -------------------------------------------------------------------------------- /release.nix: -------------------------------------------------------------------------------- 1 | { 2 | self-args ? { 3 | config.android_sdk.accept_license = true; 4 | } 5 | , local-self ? import ./. self-args 6 | , supportedSystems ? [ "x86_64-linux" "x86_64-darwin" ] 7 | }: 8 | let 9 | inherit (local-self) reflex-platform; 10 | inherit (local-self.nixpkgs) lib; 11 | 12 | perPlatform = lib.genAttrs supportedSystems (system: let 13 | self = import ./. (self-args // { inherit system; }); 14 | reflex-platform = self.reflex-platform; 15 | cachePackages = 16 | (builtins.attrValues (self.rhyolitePackages self.obelisk)) 17 | ++ [ 18 | self.proj.ghcjs.rhyolite-frontend 19 | (import ./. {}).proj.ghc.rhyolite-test-suite 20 | ]; 21 | in { 22 | cache = reflex-platform.pinBuildInputs "rhyolite-${system}" cachePackages; 23 | }); 24 | 25 | metaCache = reflex-platform.pinBuildInputs "rhyolite-everywhere" 26 | (map (a: a.cache) (builtins.attrValues perPlatform)); 27 | 28 | in perPlatform // { 29 | inherit metaCache; 30 | } 31 | -------------------------------------------------------------------------------- /semimap/semimap.cabal: -------------------------------------------------------------------------------- 1 | name: semimap 2 | version: 0.1 3 | synopsis: SemiMap and SemiSet 4 | license: BSD3 5 | author: Obsidian Systems LLC 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | category: Web 9 | build-type: Simple 10 | cabal-version: >=1.2 11 | description: This package defines the concepts of SemiMap and SemiSet. 12 | 13 | library 14 | hs-source-dirs: src 15 | build-depends: 16 | aeson 17 | , base 18 | , containers 19 | , lens 20 | , monoidal-containers 21 | , reflex 22 | 23 | exposed-modules: Rhyolite.SemiMap 24 | other-extensions: TemplateHaskell 25 | ghc-options: 26 | -Wall -fno-warn-unused-do-bind -fwarn-tabs -funbox-strict-fields 27 | -O2 -fprof-auto-calls 28 | -------------------------------------------------------------------------------- /semimap/src/Rhyolite/SemiMap.hs: -------------------------------------------------------------------------------- 1 | -- | Definition, utilities and instances for 'SemiMap' and 'SemiSet'. 2 | 3 | {-# Language DeriveFoldable #-} 4 | {-# Language DeriveFunctor #-} 5 | {-# Language DeriveGeneric #-} 6 | {-# Language LambdaCase #-} 7 | 8 | module Rhyolite.SemiMap where 9 | 10 | import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey) 11 | import Data.Coerce (coerce) 12 | import Data.Map.Monoidal as Map 13 | import Data.Maybe (isJust) 14 | import Data.Semigroup (First(..)) 15 | import Data.Set (Set) 16 | import qualified Data.Set as Set 17 | import GHC.Generics (Generic) 18 | 19 | -- | A SemiMap is a structure built on top on the 'MonoidalMap' that lets you 20 | -- distinguish two semantic meanings of the monoidal map: 21 | data SemiMap k v 22 | = SemiMap_Complete (MonoidalMap k v) 23 | -- ^ The MonoidalMap contains all the information that I want to express. 24 | | SemiMap_Partial (MonoidalMap k (First (Maybe v))) 25 | -- ^ I'm only expressing a patch for some information that I already possess. 26 | deriving (Show, Read, Eq, Ord, Foldable, Functor, Generic) 27 | 28 | isComplete :: SemiMap k v -> Bool 29 | isComplete = isJust . getComplete 30 | 31 | getComplete :: SemiMap k v -> Maybe (MonoidalMap k v) 32 | getComplete = \case 33 | SemiMap_Complete m -> Just m 34 | SemiMap_Partial _ -> Nothing 35 | 36 | knownKeysSet :: SemiMap k v -> Set k 37 | knownKeysSet = \case 38 | SemiMap_Complete x -> Map.keysSet x 39 | SemiMap_Partial x -> Map.keysSet $ Map.mapMaybe getFirst x 40 | 41 | knownKeys :: SemiMap k v -> [k] 42 | knownKeys = Set.toList . knownKeysSet 43 | 44 | knownSubMap :: SemiMap k v -> MonoidalMap k v 45 | knownSubMap = \case 46 | SemiMap_Complete m -> m 47 | SemiMap_Partial m -> Map.mapMaybe getFirst m 48 | 49 | instance (Ord k) => Monoid (SemiMap k v) where 50 | mempty = SemiMap_Partial mempty 51 | 52 | instance (Ord k) => Semigroup (SemiMap k v) where 53 | new <> old = case new of 54 | SemiMap_Complete _ -> new 55 | SemiMap_Partial p -> case old of 56 | SemiMap_Partial oldp -> SemiMap_Partial $ p <> oldp 57 | SemiMap_Complete oldc -> SemiMap_Complete $ applyMap (coerce p) (coerce oldc) 58 | where 59 | applyMap :: Ord k => MonoidalMap k (Maybe v) -> MonoidalMap k v -> MonoidalMap k v 60 | applyMap patch old' = Map.unionWith const insertions (old' `Map.difference` deletions) 61 | where (deletions, insertions) = Map.mapEither maybeToEither patch 62 | maybeToEither = \case 63 | Nothing -> Left () 64 | Just r -> Right r 65 | 66 | instance (ToJSON k, ToJSON v, ToJSONKey k) => ToJSON (SemiMap k v) 67 | instance (Ord k, FromJSON k, FromJSON v, FromJSONKey k) => FromJSON (SemiMap k v) 68 | 69 | -- | With a SemiSet, you can express that you have a complete set of elements, 70 | -- or a patch that modifies another collection. 71 | type SemiSet k = SemiMap k () 72 | 73 | fromKnown :: Set k -> SemiSet k 74 | fromKnown = SemiMap_Partial . Map.fromSet (\_ -> First $ Just ()) 75 | 76 | fromKnownAbsent :: Set k -> SemiMap k () 77 | fromKnownAbsent = SemiMap_Partial . Map.fromSet (\_ -> First Nothing) 78 | 79 | fromKnownComplete :: Set k -> SemiMap k () 80 | fromKnownComplete = SemiMap_Complete . Map.fromSet (\_ -> ()) 81 | 82 | -- | A SemiMap that knows whether a particular item is present, but doesn't know 83 | -- anything about the presence or absence of other items. 84 | singleKnownPresence 85 | :: a -- ^ The item 86 | -> Bool -- ^ Whether it is present 87 | -> SemiSet a 88 | singleKnownPresence a b = f $ Set.singleton a 89 | where f = case b of 90 | False -> fromKnownAbsent 91 | True -> fromKnown 92 | -------------------------------------------------------------------------------- /signed-data/signed-data-clientsession/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /signed-data/signed-data-clientsession/signed-data-clientsession.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: signed-data-clientsession 3 | version: 0.1.0.0 4 | synopsis: Sign data using a client session key 5 | description: 6 | Instance for MonadSign using Web.ClientSession as the key source 7 | 8 | homepage: https://github.com/obsidiansystems/rhyolite 9 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 10 | license: BSD-3-Clause 11 | license-file: LICENSE 12 | author: Obsidian Systems LLC 13 | maintainer: maintainer@obsidian.systems 14 | copyright: 2021 Obsidian Systems LLC 15 | category: Data 16 | 17 | library 18 | exposed-modules: Data.Signed.ClientSession 19 | build-depends: 20 | , aeson 21 | , base 22 | , bytestring 23 | , clientsession 24 | , monad-control 25 | , monad-logger 26 | , mtl 27 | , signed-data 28 | , text 29 | , transformers 30 | , transformers-base 31 | 32 | hs-source-dirs: src 33 | default-language: Haskell2010 34 | -------------------------------------------------------------------------------- /signed-data/signed-data-clientsession/src/Data/Signed/ClientSession.hs: -------------------------------------------------------------------------------- 1 | -- | Functions to encrypt data using "Web.ClientSession" 2 | 3 | 4 | {-# Language FlexibleContexts #-} 5 | {-# Language FlexibleInstances #-} 6 | {-# Language GeneralizedNewtypeDeriving #-} 7 | {-# Language MultiParamTypeClasses #-} 8 | {-# Language ScopedTypeVariables #-} 9 | {-# Language TemplateHaskell #-} 10 | {-# Language TypeApplications #-} 11 | {-# Language TypeFamilies #-} 12 | {-# Language UndecidableInstances #-} 13 | 14 | {-# OPTIONS_GHC -fno-warn-orphans #-} 15 | 16 | module Data.Signed.ClientSession 17 | ( module Data.Signed.ClientSession 18 | , CS.Key 19 | , CS.getKey 20 | ) where 21 | 22 | import Control.Monad (guard) 23 | import Control.Monad.Base 24 | import Control.Monad.IO.Class (MonadIO, liftIO) 25 | import Control.Monad.Logger 26 | import Control.Monad.Reader 27 | import Control.Monad.Trans.Control 28 | import Data.Aeson (FromJSON, ToJSON, encode, decodeStrict') 29 | import qualified Data.ByteString.Lazy as LBS 30 | import Data.Proxy (Proxy(..)) 31 | import Data.Text.Encoding (decodeUtf8, encodeUtf8) 32 | import Data.Typeable (Typeable, typeRep) 33 | import qualified Web.ClientSession as CS 34 | import Data.Signed (MonadSign(..), Signed(..)) 35 | 36 | signWithKey :: (Typeable a, ToJSON a, MonadIO m) => CS.Key -> a -> m (Signed a) 37 | signWithKey k (v :: a) = 38 | liftIO $ fmap (Signed . decodeUtf8) $ CS.encryptIO k $ LBS.toStrict $ encode (show $ typeRep (Proxy @a), v) 39 | 40 | readSignedWithKey :: (Typeable a, FromJSON a) => CS.Key -> Signed a -> Maybe a 41 | readSignedWithKey k s = do 42 | tvJson <- CS.decrypt k $ encodeUtf8 $ unSigned s 43 | (t, v :: b) <- decodeStrict' tvJson 44 | guard $ t == show (typeRep $ Proxy @b) 45 | return v 46 | 47 | -- We need the Typeable here because otherwise two 'Signed's whose contents encode the same way will be interchangeable 48 | sign :: (MonadSign m, SigningKey m ~ CS.Key, MonadIO m, Typeable a, ToJSON a) => a -> m (Signed a) 49 | sign a = do 50 | k <- askSigningKey 51 | signWithKey k a 52 | 53 | readSigned :: (MonadSign m, SigningKey m ~ CS.Key, Typeable a, FromJSON a) => Signed a -> m (Maybe a) 54 | readSigned s = do 55 | k <- askSigningKey 56 | pure $ readSignedWithKey k s 57 | 58 | newtype SignT m a = SignT { unSignT :: ReaderT CS.Key m a } 59 | deriving (Functor, Applicative, Monad, MonadIO, MonadTrans, MonadLogger) 60 | 61 | runSignT :: SignT m a -> CS.Key -> m a 62 | runSignT (SignT a) = runReaderT a 63 | 64 | instance MonadBase b m => MonadBase b (SignT m) where 65 | liftBase = SignT . liftBase 66 | 67 | instance MonadTransControl SignT where 68 | type StT SignT a = StT (ReaderT CS.Key) a 69 | liftWith f = SignT $ liftWith $ \g -> f $ g . unSignT 70 | restoreT a = SignT $ restoreT a 71 | 72 | instance (MonadIO m, MonadBaseControl IO m) => MonadBaseControl IO (SignT m) where 73 | type StM (SignT m) a = StM (ReaderT CS.Key m) a 74 | liftBaseWith f = SignT $ liftBaseWith $ \g -> f $ g . unSignT 75 | restoreM a = SignT $ restoreM a 76 | 77 | instance Monad m => MonadSign (SignT m) where 78 | type SigningKey (SignT m) = CS.Key 79 | askSigningKey = SignT ask 80 | 81 | -- Orphans 82 | instance MonadSign m => MonadSign (NoLoggingT m) where 83 | type SigningKey (NoLoggingT m) = SigningKey m 84 | -------------------------------------------------------------------------------- /signed-data/signed-data/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for signed-data 2 | 3 | ## 0.1.0.0 -- YYYY-mm-dd 4 | 5 | * First version. Released on an unsuspecting world. 6 | -------------------------------------------------------------------------------- /signed-data/signed-data/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /signed-data/signed-data/signed-data.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: signed-data 3 | version: 0.1.0.0 4 | synopsis: Types for signed data 5 | description: 6 | Types and a monad for working single-key encrypted data. Useful for, e.g., signing cookies. 7 | 8 | homepage: https://github.com/obsidiansystems/rhyolite 9 | bug-reports: https://github.com/obsidiansystems/rhyolite/issues 10 | license: BSD-3-Clause 11 | license-file: LICENSE 12 | author: Obsidian Systems LLC 13 | maintainer: maintainer@obsidian.systems 14 | copyright: 2021 Obsidian Systems LLC 15 | category: Data 16 | extra-source-files: CHANGELOG.md 17 | 18 | library 19 | exposed-modules: Data.Signed 20 | build-depends: 21 | , aeson 22 | , base 23 | , mtl 24 | , text 25 | , transformers 26 | 27 | hs-source-dirs: src 28 | default-language: Haskell2010 29 | -------------------------------------------------------------------------------- /signed-data/signed-data/src/Data/Signed.hs: -------------------------------------------------------------------------------- 1 | -- | Infrastructure for signing data, which you would use, e.g. when signing cookies. 2 | 3 | {-# Language DefaultSignatures #-} 4 | {-# Language FlexibleContexts #-} 5 | {-# Language FlexibleInstances #-} 6 | {-# Language GADTs #-} 7 | {-# Language GeneralizedNewtypeDeriving #-} 8 | {-# Language ScopedTypeVariables #-} 9 | {-# Language TypeFamilies #-} 10 | {-# Language UndecidableInstances #-} 11 | 12 | module Data.Signed where 13 | 14 | import Data.Text (Text) 15 | import Data.Aeson (FromJSON, ToJSON, FromJSONKey, ToJSONKey) 16 | import Control.Monad.Reader (ReaderT) 17 | import Control.Monad.State (StateT) 18 | import Control.Monad.Trans (MonadTrans (lift)) 19 | import Control.Monad.Trans.Except (ExceptT) 20 | import Control.Monad.Trans.Maybe (MaybeT) 21 | import qualified Control.Monad.State.Strict as Strict 22 | 23 | -- | A wrapper around 'Text' that contains the signed payload. 24 | newtype Signed a = Signed { unSigned :: Text } 25 | deriving (Show, Read, Eq, Ord, ToJSON, FromJSON, ToJSONKey, FromJSONKey) 26 | 27 | class Monad m => MonadSign m where 28 | type SigningKey m 29 | askSigningKey :: m (SigningKey m) 30 | default askSigningKey 31 | :: (m ~ t m', SigningKey (t m') ~ SigningKey m', MonadTrans t, MonadSign m', Monad m') 32 | => m (SigningKey m) 33 | askSigningKey = lift askSigningKey 34 | 35 | instance MonadSign m => MonadSign (ReaderT r m) where 36 | type SigningKey (ReaderT r m) = SigningKey m 37 | 38 | instance MonadSign m => MonadSign (StateT s m) where 39 | type SigningKey (StateT s m) = SigningKey m 40 | 41 | instance MonadSign m => MonadSign (Strict.StateT s m) where 42 | type SigningKey (Strict.StateT s m) = SigningKey m 43 | 44 | instance MonadSign m => MonadSign (MaybeT m) where 45 | type SigningKey (MaybeT m) = SigningKey m 46 | 47 | instance MonadSign m => MonadSign (ExceptT e m) where 48 | type SigningKey (ExceptT e m) = SigningKey m 49 | -------------------------------------------------------------------------------- /test/rhyolite-test-suite.cabal: -------------------------------------------------------------------------------- 1 | name: rhyolite-test-suite 2 | version: 0.1 3 | synopsis: Tests for rhyolite 4 | license: BSD3 5 | author: Obsidian Systems LLC 6 | maintainer: maintainer@obsidian.systems 7 | stability: Experimental 8 | category: Web 9 | build-type: Simple 10 | cabal-version: >=1.2 11 | 12 | executable rhyolite-test-suite 13 | hs-source-dirs: src-bin 14 | main-is: test.hs 15 | build-depends: 16 | base 17 | , bytestring-aeson-orphans 18 | , psql-simple-beam 19 | , psql-simple-class 20 | , rhyolite-account-backend 21 | , rhyolite-account-types 22 | , rhyolite-backend 23 | , rhyolite-beam-task-worker-backend 24 | , rhyolite-common 25 | , rhyolite-email 26 | , rhyolite-frontend 27 | , rhyolite-notify-listen 28 | , rhyolite-notify-listen-beam 29 | , rhyolite-widgets 30 | , semimap 31 | , signed-data 32 | , signed-data-clientsession 33 | 34 | ghc-options: 35 | -threaded -Wall -fno-warn-unused-do-bind -fwarn-tabs 36 | -funbox-strict-fields -O2 -fprof-auto-calls -rtsopts 37 | -------------------------------------------------------------------------------- /test/src-bin/test.hs: -------------------------------------------------------------------------------- 1 | {-# Language PackageImports #-} 2 | {-# OPTIONS_GHC -Wno-unused-imports #-} 3 | module Main where 4 | 5 | --common 6 | import Rhyolite.App 7 | -- backend 8 | import Rhyolite.Backend.App 9 | -- frontend 10 | import Rhyolite.Frontend.App 11 | -- semimap 12 | import Rhyolite.SemiMap 13 | -- rhyolite-widgets 14 | import qualified Reflex.Dom.Widget.ExtensibleList 15 | 16 | -- psql-extras 17 | import qualified Database.PostgreSQL.Simple.Class 18 | import qualified Database.PostgreSQL.Simple.Beam 19 | 20 | -- notify-listen 21 | import qualified Rhyolite.DB.NotifyListen 22 | import qualified Rhyolite.DB.NotifyListen.Beam 23 | 24 | -- email 25 | import qualified Rhyolite.Email 26 | 27 | -- signed-data 28 | import qualified Data.Signed 29 | -- signed-data-clientsession 30 | import qualified Data.Signed.ClientSession 31 | 32 | -- accounts 33 | import qualified "rhyolite-account-types" Rhyolite.Account 34 | import qualified "rhyolite-account-backend" Rhyolite.Backend.Account 35 | 36 | main :: IO () 37 | main = putStrLn "rhyolite submodules were successfully built" 38 | -------------------------------------------------------------------------------- /widgets/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for rhyolite-widgets 2 | 3 | ## 0.1.0.0 -- YYYY-mm-dd 4 | 5 | * First version. Released on an unsuspecting world. 6 | -------------------------------------------------------------------------------- /widgets/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Obsidian Systems LLC 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Obsidian Systems LLC nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /widgets/rhyolite-widgets.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.4 2 | name: rhyolite-widgets 3 | version: 0.1.0.0 4 | synopsis: Various widgets and controls 5 | description: 6 | Reusable, fairly general widgets and controls for reflex-dom UIs 7 | 8 | homepage: https://github.com/obsidiansystems/rhyolite 9 | bug-reports: https://github.com/obsidiansystems/rhyolite 10 | license: BSD-3-Clause 11 | license-file: LICENSE 12 | author: Obsidian Systems LLC 13 | maintainer: maintainer@obsidian.systems 14 | copyright: Obsidian Systems LLC 15 | category: UI 16 | 17 | library 18 | exposed-modules: 19 | Reflex.Dom.Widget.ExtensibleList 20 | Reflex.Dom.Widget.Form 21 | 22 | build-depends: 23 | , base 24 | , containers 25 | , either 26 | , ghcjs-dom 27 | , jsaddle 28 | , lens 29 | , mtl 30 | , obelisk-executable-config-lookup 31 | , obelisk-route 32 | , primitive 33 | , ref-tf 34 | , reflex 35 | , reflex-dom-core 36 | , text 37 | , transformers 38 | , validation 39 | 40 | hs-source-dirs: src 41 | default-language: Haskell2010 42 | -------------------------------------------------------------------------------- /widgets/src/Reflex/Dom/Widget/ExtensibleList.hs: -------------------------------------------------------------------------------- 1 | -- | A couple utilities to deal with list of items in which each item can return 2 | -- controls for insertion before and after, and deletion. See 'ListEdit' and 3 | -- 'extensibleListWidget'. 4 | 5 | {-# Language LambdaCase #-} 6 | {-# Language OverloadedStrings #-} 7 | {-# Language RecursiveDo #-} 8 | {-# Language ScopedTypeVariables #-} 9 | module Reflex.Dom.Widget.ExtensibleList where 10 | 11 | import Control.Monad.Fix 12 | import Data.Either.Combinators (rightToMaybe) 13 | import Data.Map (Map) 14 | import qualified Data.Map as Map 15 | import Data.Text (Text) 16 | import Reflex.Dom.Core hiding (Delete) 17 | 18 | data ListEdit = ListEdit_InsertBefore | ListEdit_Delete | ListEdit_InsertAfter 19 | deriving (Eq, Ord, Show, Read) 20 | 21 | extensibleListWidget 22 | :: forall t m a b. (DomBuilder t m, MonadHold t m, MonadFix m) 23 | => Int -- ^ Minimum number of entries (be careful: if this is 0, the entire list is allowed to vanish) 24 | -> a -- ^ Initial entry for newly inserted items 25 | -> [a] -- ^ Initial sequence of entries 26 | -> Event t () -- ^ Add an item at the end of the list 27 | -> (Dynamic t Int -> a -> m (Event t ListEdit, Dynamic t b)) 28 | -- ^ Widget for a single item which is expected to include 29 | -- the list editing controls and pass through the resulting events. 30 | -> m (Dynamic t [b]) 31 | extensibleListWidget n x0 xs0 addAtEnd itemWidget = extensibleListWidgetWithSize n x0 xs0 addAtEnd (\d -> itemWidget (fst <$> d)) 32 | 33 | -- | Like `extensibleListWidget`, but the items know the current size of the whole list, as well as their position. 34 | extensibleListWidgetWithSize 35 | :: forall t m a b. (DomBuilder t m, MonadHold t m, MonadFix m) 36 | => Int -- ^ Minimum number of entries (be careful: if this is 0, the entire list is allowed to vanish) 37 | -> a -- ^ Initial entry for newly inserted items 38 | -> [a] -- ^ Initial sequence of entries 39 | -> Event t () -- ^ Add an item at the end of the list 40 | -> (Dynamic t (Int, Int) -> a -> m (Event t ListEdit, Dynamic t b)) 41 | -- ^ Widget for a single item which is expected to include 42 | -- the list editing controls and pass through the resulting events. 43 | -- First argument is (item position, total number of items). 44 | -> m (Dynamic t [b]) 45 | extensibleListWidgetWithSize n x0 xs0 addAtEnd itemWidget = do 46 | let genIndex :: Map Rational a -> Map Rational a -> Rational 47 | genIndex us vs = 48 | case (Map.maxViewWithKey us, Map.minViewWithKey vs) of 49 | (Nothing , Nothing ) -> 0 50 | (Nothing , Just ((v, _), _)) -> v - 1 51 | (Just ((u, _), _), Nothing ) -> u + 1 52 | (Just ((u, _), _), Just ((v, _), _)) -> (u + v) / 2 53 | handleChange :: (Rational, ListEdit) -> Map Rational a -> Map Rational (Maybe a) 54 | handleChange (k, ListEdit_InsertBefore) xs = 55 | let (us, x, vs) = Map.splitLookup k xs 56 | vs' = Map.alter (const x) k vs 57 | i = genIndex us vs' 58 | in Map.singleton i (Just x0) 59 | handleChange (k, ListEdit_Delete) xs = 60 | if Map.size xs > n 61 | then Map.singleton k Nothing 62 | else Map.singleton k (Just x0) 63 | handleChange (k, ListEdit_InsertAfter) xs = 64 | let (us, x, vs) = Map.splitLookup k xs 65 | us' = Map.alter (const x) k us 66 | i = genIndex us' vs 67 | in Map.singleton i (Just x0) 68 | map0 :: Map Rational (Maybe a) = Map.fromList . zip [0..] $ fmap Just xs0 69 | rec let attachList xs x = case x of 70 | Nothing -> case Map.maxViewWithKey xs of 71 | Nothing -> handleChange (0, ListEdit_InsertAfter) xs 72 | Just ((k, _), _) -> handleChange (k, ListEdit_InsertAfter) xs 73 | Just x' -> handleChange x' xs 74 | updateEvent :: Event t (Map Rational (Maybe a)) = attachWith attachList (current listMapD) $ leftmost 75 | [ Just <$> changeMapE 76 | , Nothing <$ addAtEnd 77 | ] 78 | listMapD :: Dynamic t (Map Rational a) <- fmap (Map.mapMaybe id) <$> foldDyn (\e m -> Map.union e m) map0 updateEvent 79 | let ixMapD :: Dynamic t (Map Rational Int) = fmap (Map.fromList . (`zip` [0::Int ..]) . Map.keys) listMapD 80 | resultMapD <- listHoldWithKey (Map.mapMaybe id map0) updateEvent $ \k v -> do 81 | let ix = fmap (Map.findWithDefault (-1) k) ixMapD 82 | -- TODO, maybe: figure out why this Map lookup is too strict. 83 | -- Deleting an item causes a failed lookup, however, I'm not sure it really matters. 84 | itemWidget ((,) <$> ix <*> fmap length listMapD) v 85 | let changeMapE = switch . current $ fmap (leftmost . fmap (\(k, v) -> fmap ((,) k) v) . Map.toList . fmap fst) resultMapD 86 | valuesMapD = joinDynThroughMap $ fmap (fmap snd) resultMapD 87 | valuesD = fmap Map.elems valuesMapD 88 | return valuesD 89 | --------------------------------------------------------------------------------