├── .gitattributes ├── .gitignore ├── Makefile ├── README.md ├── db ├── create_or_update_db.sh ├── create_or_update_roles.sh └── migrations │ ├── 000.sql │ ├── 001.sql │ └── 002.sql ├── default.nix ├── hs-pkgs ├── README.md ├── nixtodo-api-client │ ├── Setup.hs │ ├── cabal.config │ ├── nixtodo-api-client.cabal │ ├── shell.nix │ └── src │ │ └── Nixtodo │ │ └── Api │ │ └── Client.hs ├── nixtodo-api │ ├── Setup.hs │ ├── nixtodo-api.cabal │ ├── shell.nix │ └── src │ │ └── Nixtodo │ │ └── Api.hs ├── nixtodo-backend │ ├── Setup.hs │ ├── nixtodo-backend.cabal │ ├── shell.nix │ └── src │ │ ├── Nixtodo │ │ └── Backend │ │ │ ├── Db.hs │ │ │ ├── Db │ │ │ └── Types.hs │ │ │ ├── IndexTemplater.hs │ │ │ └── WebServer.hs │ │ └── nixtodo-backend.hs └── nixtodo-frontend │ ├── Setup.hs │ ├── cabal.config │ ├── index.html.mustache │ ├── nixtodo-frontend.cabal │ ├── override.nix │ ├── shell.nix │ ├── src │ ├── Servant │ │ └── Client │ │ │ └── Ghcjs │ │ │ └── Extended.hs │ └── nixtodo-frontend.hs │ └── static │ ├── default.css │ ├── favicon.ico │ ├── haskell.png │ ├── nix.png │ └── style.css ├── jobset.nix ├── modules ├── README.md ├── backend │ ├── README.md │ ├── db.nix │ ├── default.nix │ └── server.nix ├── base.nix ├── cache.nixtodo.com-public-key ├── default.nix ├── github.com-rsa_key.pub ├── hydra.nix ├── nixtodo-net │ ├── README.md │ ├── aws.nix │ ├── containerized.nix │ └── default.nix └── users │ ├── bas │ └── .ssh │ │ └── id_rsa.pub │ └── peti │ └── .ssh │ └── id_rsa.pub ├── nix ├── README.md ├── haskell-overrides.nix ├── haskell │ ├── servant-client-core │ │ └── default.nix │ ├── servant-client-ghcjs │ │ └── default.nix │ ├── servant-client │ │ └── default.nix │ ├── servant-server │ │ └── default.nix │ ├── servant-swagger │ │ └── default.nix │ └── servant │ │ └── default.nix └── overlay.nix ├── nixpkgs.nix ├── release.nix ├── secrets ├── README.md ├── cache.nixtodo.com-secret-key ├── devops-hydra-password ├── dhparams.pem ├── hydra-github.id_rsa ├── hydra-github.id_rsa.pub ├── postgresql-nixtodo-role-password └── state.nixops └── spec.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Encrypt all secret files under ./secrets 2 | secrets/** filter=git-crypt diff=git-crypt 3 | secrets/README.md !filter !diff 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | result 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # 3 | # This Makefile is our entry point into the nixtodo project. We use it to: 4 | # 5 | # * get into development environments for working on our Haskell packages, 6 | # 7 | # * provisioning and deploying containers on our workstation for testing the 8 | # system, 9 | # 10 | # * provisioning and deploying EC2 instances, elastic IPs, key-pairs, security 11 | # groups and other AWS resources to run our production system. 12 | # 13 | # With this Makefile you almost never have to type in actual nix commands for 14 | # day-to-day develoment and operational work. 15 | # 16 | ################################################################################ 17 | 18 | # Some of our Nix expressions refer to and . In order for 19 | # them to find these directories we have to put them in the NIX_PATH environment 20 | # variable. 21 | export NIX_PATH := nixpkgs=$(shell nix-build --no-out-link ./nixpkgs.nix):nixtodo=$(shell pwd) 22 | 23 | help: 24 | cat Makefile 25 | 26 | 27 | ################################################################################ 28 | # Development 29 | ################################################################################ 30 | 31 | # The following are some handy targets for building our Haskell packages and 32 | # getting into shells which are setup with all the dependencies needed to work 33 | # on a package. 34 | 35 | .PHONY: nixtodo-frontend.build 36 | nixtodo-frontend.build: 37 | nix-build -A haskell.packages.ghcjsHEAD.nixtodo-frontend 38 | 39 | .PHONY: nixtodo-frontend.shell 40 | nixtodo-frontend.shell: 41 | cd hs-pkgs/nixtodo-frontend && \ 42 | nix-shell --command 'cabal configure; return' 43 | 44 | .PHONY: nixtodo-api.build.ghcjs 45 | nixtodo-api.build.ghcjs: 46 | nix-build -A haskell.packages.ghcjsHEAD.nixtodo-api 47 | 48 | .PHONY: nixtodo-api.build.ghc 49 | nixtodo-api.build.ghc: 50 | nix-build -A haskellPackages.nixtodo-api 51 | 52 | .PHONY: nixtodo-api.shell 53 | nixtodo-api.shell: 54 | cd hs-pkgs/nixtodo-api && \ 55 | nix-shell --command 'cabal configure; return' 56 | 57 | .PHONY: nixtodo-api-client.shell 58 | nixtodo-api-client.shell: 59 | cd hs-pkgs/nixtodo-api-client && \ 60 | nix-shell --command 'cabal configure; return' 61 | 62 | .PHONY: nixtodo-backend.build 63 | nixtodo-backend.build: 64 | nix-build -A haskellPackages.nixtodo-backend 65 | 66 | .PHONY: nixtodo-backend.shell 67 | nixtodo-backend.shell: 68 | cd hs-pkgs/nixtodo-backend && \ 69 | nix-shell --command 'cabal configure; return' 70 | 71 | MK_SECRET := openssl rand -base64 32 72 | 73 | secrets/devops-hydra-password: 74 | $(MK_SECRET) > $@ 75 | 76 | secrets/postgresql-nixtodo-role-password: 77 | $(MK_SECRET) > $@ 78 | 79 | secrets/dhparams.pem: 80 | openssl dhparam 4096 -out $@ 81 | 82 | secrets/hydra-github.id_rsa: 83 | ssh-keygen -C hydra-github -P "" -f $@ 84 | 85 | secrets/cache.nixtodo.com-secret-key: 86 | nix-store --generate-binary-cache-key cache.nixtodo.com-1 \ 87 | $@ modules/cache.nixtodo.com-public-key 88 | 89 | ################################################################################ 90 | # Deploying to containers for testing 91 | ################################################################################ 92 | 93 | # For testing our complete nixtodo system we can take our nixops network 94 | # specification () and deploy it to containers on 95 | # our workstation. These containers can then be managed by the `nixos-container` 96 | # command. 97 | # 98 | # The following targets can be used to create this containerized network and 99 | # deploy it. 100 | 101 | .PHONY: containerized-nixtodo-net.create 102 | containerized-nixtodo-net.create: 103 | nixops create -d containerized-nixtodo-net \ 104 | '' \ 105 | '' 106 | 107 | .PHONY: containerized-nixtodo-net.modify 108 | containerized-nixtodo-net.modify: 109 | nixops modify -d containerized-nixtodo-net \ 110 | '' \ 111 | '' 112 | 113 | .PHONY: containerized-nixtodo-net.info 114 | containerized-nixtodo-net.info: 115 | nixops info -d containerized-nixtodo-net 116 | 117 | .PHONY: containerized-nixtodo-net.build 118 | containerized-nixtodo-net.build: 119 | nixops deploy -d containerized-nixtodo-net --build-only 120 | 121 | .PHONY: containerized-nixtodo-net.copy 122 | containerized-nixtodo-net.copy: 123 | nixops deploy -d containerized-nixtodo-net --copy-only 124 | 125 | .PHONY: containerized-nixtodo-net.deploy 126 | containerized-nixtodo-net.deploy: 127 | nixops deploy -d containerized-nixtodo-net 128 | 129 | .PHONY: containerized-nixtodo-net.destroy 130 | containerized-nixtodo-net.destroy: 131 | nixops destroy -d containerized-nixtodo-net 132 | 133 | .PHONY: containerized-nixtodo-net.delete 134 | containerized-nixtodo-net.delete: 135 | nixops delete -d containerized-nixtodo-net 136 | 137 | .PHONY: containerized-backend.ssh 138 | containerized-backend.ssh: 139 | nixops ssh -d containerized-nixtodo-net backend 140 | 141 | .PHONY: containerized-backend.destroy 142 | containerized-backend.destroy: 143 | nixops destroy -d containerized-nixtodo-net --include backend 144 | 145 | .PHONY: containerized-support.ssh 146 | containerized-support.ssh: 147 | nixops ssh -d containerized-nixtodo-net support 148 | 149 | .PHONY: containerized-support.destroy 150 | containerized-support.destroy: 151 | nixops destroy -d containerized-nixtodo-net --include support 152 | 153 | 154 | ################################################################################ 155 | # Deploying to production 156 | ################################################################################ 157 | 158 | # nixtodo.com is hosted on AWS. The following targets can be used to provision 159 | # all the needed resources (like EC2 instances, elastic IPs, key pairs and 160 | # security groups) and bring them in the configuration as specified by 161 | # . 162 | 163 | .PHONY: nixtodo-net.create 164 | nixtodo-net.create: 165 | nixops create -s secrets/state.nixops -d nixtodo-net \ 166 | '' \ 167 | '' 168 | 169 | .PHONY: nixtodo-net.modify 170 | nixtodo-net.modify: 171 | nixops modify -s secrets/state.nixops -d nixtodo-net \ 172 | '' \ 173 | '' 174 | 175 | .PHONY: nixtodo-net.info 176 | nixtodo-net.info: 177 | nixops info -s secrets/state.nixops -d nixtodo-net 178 | 179 | .PHONY: nixtodo-net.build 180 | nixtodo-net.build: 181 | nixops deploy -s secrets/state.nixops -d nixtodo-net --build-only 182 | 183 | .PHONY: nixtodo-net.copy 184 | nixtodo-net.copy: 185 | nixops deploy -s secrets/state.nixops -d nixtodo-net --copy-only 186 | 187 | .PHONY: nixtodo-net.deploy 188 | nixtodo-net.deploy: 189 | nixops deploy -s secrets/state.nixops -d nixtodo-net 190 | 191 | .PHONY: nixtodo-net.destroy 192 | nixtodo-net.destroy: 193 | nixops destroy -s secrets/state.nixops -d nixtodo-net 194 | 195 | .PHONY: nixtodo-net.delete 196 | nixtodo-net.delete: 197 | nixops delete -s secrets/state.nixops -d nixtodo-net 198 | 199 | .PHONY: backend.ssh 200 | backend.ssh: 201 | nixops ssh -s secrets/state.nixops -d nixtodo-net backend 202 | 203 | .PHONY: backend.destroy 204 | backend.destroy: 205 | nixops destroy -s secrets/state.nixops -d nixtodo-net --include backend 206 | 207 | .PHONY: support.ssh 208 | support.ssh: 209 | nixops ssh -s secrets/state.nixops -d nixtodo-net support 210 | 211 | .PHONY: support.destroy 212 | support.destroy: 213 | nixops destroy -s secrets/state.nixops -d nixtodo-net --include support 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nixtodo.com 2 | =========== 3 | 4 | A TODO list web-app powered by Haskell & Nix. 5 | 6 | Hosted at: https://nixtodo.com and running on AWS. 7 | 8 | Please study the Makefile since that's the entry point into the 9 | nixtodo project. 10 | 11 | A Continuous Integration server is running at https://hydra.nixtodo.com and a 12 | Nix binary cache is served from https://cache.nixtodo.com. 13 | -------------------------------------------------------------------------------- /db/create_or_update_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -o errexit 4 | 5 | DB_NAME="${1}" 6 | 7 | PORT="${2}" 8 | 9 | export PGPASSWORD 10 | PGPASSWORD="$(cat ${3})" 11 | 12 | MIGRATIONS="${4}" 13 | 14 | PSQL_NIXTODO="psql\ 15 | --host=127.0.0.1\ 16 | --port=${PORT}\ 17 | --username=nixtodo\ 18 | --no-psqlrc\ 19 | --quiet\ 20 | --set=ON_ERROR_STOP=on\ 21 | --set=AUTOCOMMIT=off" 22 | 23 | dbExists() { 24 | ${PSQL_NIXTODO} --list --tuples-only | \ 25 | cut -d \| -f 1 | grep "^ *${1} *$" | wc -l 26 | } 27 | 28 | if [ $(dbExists "${DB_NAME}") == "0" ]; then 29 | (cat < %', start_version, initial_version; 122 | END IF; 123 | END IF; 124 | END; 125 | $fun$ LANGUAGE plpgsql STRICT; 126 | COMMENT ON FUNCTION maintenance.start_migration(text,text,text) IS 127 | 'Start a migration from a start version of a schema to another version. 128 | Call this function in a transaction that performs the whole migration. 129 | The function will throw an exception if the DB schema version does not 130 | match the start version; thereby aborting the migration transaction. You 131 | MUST call ''maintenance.finish_migration()'' to mark the migration as 132 | finished.'; 133 | 134 | -------------------------------------------------------------------------- 135 | 136 | CREATE FUNCTION maintenance.finish_migration() RETURNS void AS $fun$ 137 | DECLARE 138 | lm maintenance.latest_migration%ROWTYPE; 139 | BEGIN 140 | IF EXISTS (SELECT * FROM maintenance.migrations_in_progress OFFSET 1) THEN 141 | RAISE EXCEPTION 'Cannot finish migration, as there is more than one migration in progress.' 142 | USING HINT = 'Check the table ''maintenance.migrations''.'; 143 | ELSE 144 | SELECT * 145 | INTO STRICT lm 146 | FROM maintenance.latest_migration; 147 | 148 | IF lm.in_progress THEN 149 | UPDATE maintenance.migrations 150 | SET in_progress = false 151 | WHERE migration = lm.migration; 152 | RAISE LOG 'Migrated to: %', lm.to_version; 153 | ELSE 154 | RAISE EXCEPTION 'Cannot finish migration to %, as the latest migration is no longer in progress.', lm.to_version 155 | USING HINT = 'Check the table ''maintenance.migrations''.'; 156 | END IF; 157 | END IF; 158 | END; 159 | $fun$ LANGUAGE plpgsql STRICT; 160 | COMMENT ON FUNCTION maintenance.finish_migration() IS 161 | 'Mark that the migration started with 162 | ''maintenance.start_migration(text,text,text)'' is finished.'; 163 | 164 | 165 | ---------------------------------------------------------------------------- 166 | -- Finish initialization of migration table support 167 | ---------------------------------------------------------------------------- 168 | 169 | -- Ensure that search path is safe again 170 | SET search_path TO public, pg_temp; 171 | 172 | -- Create first migration entry. 173 | INSERT INTO maintenance.migrations 174 | ( migration 175 | , to_version 176 | , description 177 | , in_progress 178 | ) 179 | VALUES ( 0 180 | , initial_version 181 | , 'Initialized DB maintenance functionality.' 182 | , false 183 | ); 184 | 185 | RAISE LOG 'Migrated to: %', maintenance.db_version(); 186 | 187 | EXCEPTION 188 | WHEN raise_exception THEN 189 | IF left(ltrim(SQLERRM),8) = 'SKIPPING' THEN 190 | RAISE NOTICE '%', SQLERRM; 191 | ELSE 192 | RAISE EXCEPTION 'Migration failed: %', SQLERRM; 193 | END IF; 194 | END; 195 | $$ LANGUAGE plpgsql; 196 | 197 | END; 198 | -------------------------------------------------------------------------------- /db/migrations/001.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | DO $block$ 4 | BEGIN 5 | 6 | PERFORM maintenance.start_migration( '0', '1', 'Add entries table'); 7 | 8 | CREATE TABLE entries 9 | ( id serial 10 | , description text NOT NULL 11 | , completed bool NOT NULL 12 | , CONSTRAINT pk_entries PRIMARY KEY (id) 13 | ); 14 | 15 | PERFORM maintenance.finish_migration(); 16 | 17 | EXCEPTION 18 | WHEN raise_exception THEN 19 | IF left(ltrim(SQLERRM),8) = 'SKIPPING' THEN 20 | RAISE NOTICE '%', SQLERRM; 21 | ELSE 22 | RAISE EXCEPTION 'Migration failed: %', SQLERRM; 23 | END IF; 24 | END 25 | $block$ LANGUAGE plpgsql; 26 | 27 | END; 28 | -------------------------------------------------------------------------------- /db/migrations/002.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | DO $block$ 4 | BEGIN 5 | 6 | PERFORM maintenance.start_migration( '1', '2', 'events'); 7 | 8 | CREATE FUNCTION send_event() RETURNS trigger AS $send_event$ 9 | BEGIN 10 | IF TG_OP = 'DELETE' 11 | THEN 12 | EXECUTE pg_notify('event_channel', TG_OP || ':' || OLD.id); 13 | ELSE 14 | EXECUTE pg_notify('event_channel', TG_OP || ':' || NEW.id); 15 | END IF; 16 | RETURN NULL; 17 | END; 18 | $send_event$ LANGUAGE plpgsql; 19 | 20 | CREATE TRIGGER send_event_trigger 21 | AFTER INSERT OR UPDATE OR DELETE 22 | ON entries 23 | FOR EACH ROW 24 | EXECUTE PROCEDURE send_event(); 25 | 26 | PERFORM maintenance.finish_migration(); 27 | 28 | EXCEPTION 29 | WHEN raise_exception THEN 30 | IF left(ltrim(SQLERRM),8) = 'SKIPPING' THEN 31 | RAISE NOTICE '%', SQLERRM; 32 | ELSE 33 | RAISE EXCEPTION 'Migration failed: %', SQLERRM; 34 | END IF; 35 | END 36 | $block$ LANGUAGE plpgsql; 37 | 38 | END; 39 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | # This Nix expression returns the package set from nixpkgs but extended and 2 | # overridden by our own packages as specified by the given overlays. 3 | 4 | let 5 | nixpkgsPath = import ./nixpkgs.nix; 6 | nixpkgs = import nixpkgsPath; 7 | in nixpkgs { overlays = [ (import ./nix/overlay.nix) ]; } 8 | -------------------------------------------------------------------------------- /hs-pkgs/README.md: -------------------------------------------------------------------------------- 1 | This directory contains all our Haskell packages. 2 | 3 | The Nix attribute `pkgs.haskellPackages` and `pkgs.haskell.packages.ghcjsHEAD` 4 | is automatically extended with all the packages listed in this directory. See 5 | `` how this is accomplished. 6 | 7 | 3rd party packages should be stored under ``. 8 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api-client/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api-client/cabal.config: -------------------------------------------------------------------------------- 1 | compiler: ghcjs -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api-client/nixtodo-api-client.cabal: -------------------------------------------------------------------------------- 1 | name: nixtodo-api-client 2 | version: 0.1.0.0 3 | synopsis: TODO-list API 4 | homepage: https://github.com/basvandijk/nix-workshop 5 | license: BSD3 6 | author: Bas van Dijk 7 | maintainer: v.dijk.bas@gmail.com 8 | build-type: Simple 9 | extra-source-files: ChangeLog.md 10 | cabal-version: >=1.10 11 | 12 | library 13 | build-depends: base 14 | , nixtodo-api 15 | , servant 16 | , servant-client-core 17 | default-language: Haskell2010 18 | exposed-modules: Nixtodo.Api.Client 19 | hs-source-dirs: src -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api-client/shell.nix: -------------------------------------------------------------------------------- 1 | (import ../..).haskell.packages.ghcjsHEAD.nixtodo-api-client.env 2 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api-client/src/Nixtodo/Api/Client.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TypeOperators #-} 2 | {-# LANGUAGE DataKinds #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | {-# LANGUAGE RankNTypes #-} 5 | {-# LANGUAGE ScopedTypeVariables #-} 6 | {-# LANGUAGE TypeApplications #-} 7 | {-# LANGUAGE FlexibleContexts #-} 8 | {-# LANGUAGE PackageImports #-} 9 | 10 | module Nixtodo.Api.Client where 11 | 12 | import "base" Data.Proxy 13 | import "nixtodo-api" Nixtodo.Api 14 | import "servant-client-core" Servant.Client.Core 15 | import "servant" Servant.API 16 | 17 | data NixtodoApiClient m 18 | = NixtodoApiClient 19 | { createEntry :: !(Client m CreateEntry) 20 | , readEntries :: !(Client m ReadEntries) 21 | , updateEntry :: !(Client m UpdateEntry) 22 | , deleteEntry :: !(Client m DeleteEntry) 23 | } 24 | 25 | nixtodoApiClient :: forall m . HasClient m NixtodoApi => NixtodoApiClient m 26 | nixtodoApiClient = NixtodoApiClient{..} 27 | where 28 | ( createEntry 29 | :<|> readEntries 30 | :<|> updateEntry 31 | :<|> deleteEntry 32 | ):<|> _websocketApi 33 | :<|> _frontendApi = Proxy @NixtodoApi `clientIn` Proxy @m 34 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api/nixtodo-api.cabal: -------------------------------------------------------------------------------- 1 | name: nixtodo-api 2 | version: 0.1.0.0 3 | synopsis: TODO-list API 4 | homepage: https://github.com/basvandijk/nix-workshop 5 | license: BSD3 6 | author: Bas van Dijk 7 | maintainer: v.dijk.bas@gmail.com 8 | build-type: Simple 9 | extra-source-files: ChangeLog.md 10 | cabal-version: >=1.10 11 | 12 | library 13 | build-depends: base 14 | , aeson 15 | , containers 16 | , lens 17 | , servant 18 | , text 19 | , product-profunctors 20 | default-language: Haskell2010 21 | exposed-modules: Nixtodo.Api 22 | hs-source-dirs: src -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api/shell.nix: -------------------------------------------------------------------------------- 1 | (import ../..).haskellPackages.nixtodo-api.env 2 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-api/src/Nixtodo/Api.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE TypeOperators #-} 3 | {-# LANGUAGE DataKinds #-} 4 | {-# LANGUAGE TemplateHaskell #-} 5 | {-# LANGUAGE TypeSynonymInstances #-} 6 | {-# LANGUAGE FlexibleInstances #-} 7 | {-# LANGUAGE MultiParamTypeClasses #-} 8 | {-# LANGUAGE PackageImports #-} 9 | 10 | module Nixtodo.Api where 11 | 12 | import "servant" Servant.API 13 | import "lens" Control.Lens (makeLenses) 14 | import qualified "text" Data.Text as T 15 | import "base" Data.List (stripPrefix) 16 | import "base" Data.Char (isUpper, toLower) 17 | import "aeson" Data.Aeson (ToJSON(..), FromJSON(..), genericToJSON, genericParseJSON) 18 | import "aeson" Data.Aeson.Types (Options, defaultOptions, fieldLabelModifier) 19 | import "base" GHC.Generics (Generic) 20 | import "product-profunctors" Data.Profunctor.Product.TH ( makeAdaptorAndInstance ) 21 | 22 | -------------------------------------------------------------------------------- 23 | -- Endpoints 24 | -------------------------------------------------------------------------------- 25 | 26 | type NixtodoApi = 27 | "entries" :> NixtodoRestApi 28 | :<|> "websocket" :> Raw 29 | :<|> FrontendApi 30 | 31 | type NixtodoRestApi = 32 | CreateEntry 33 | :<|> ReadEntries 34 | :<|> UpdateEntry 35 | :<|> DeleteEntry 36 | 37 | type CreateEntry = 38 | ReqBody '[JSON] EntryInfo 39 | :> Post '[JSON] Entry 40 | 41 | type ReadEntries = 42 | Get '[JSON] [Entry] 43 | 44 | type UpdateEntry = 45 | CaptureEntryId 46 | :> ReqBody '[JSON] EntryInfo 47 | :> Put '[JSON] NoContent 48 | 49 | type DeleteEntry = 50 | CaptureEntryId 51 | :> Delete '[JSON] NoContent 52 | 53 | type CaptureEntryId = Capture "eid" EntryId 54 | 55 | type FrontendApi = 56 | GetStatic 57 | :<|> GetHashed 58 | :<|> GetIndex 59 | 60 | type GetStatic = "static" :> Raw 61 | type GetHashed = "hashed" :> Raw 62 | type GetIndex = Raw 63 | 64 | 65 | -------------------------------------------------------------------------------- 66 | -- Types 67 | -------------------------------------------------------------------------------- 68 | 69 | data EntryEvent = 70 | UpsertEntryEvent Entry 71 | | DeleteEntryEvent EntryId 72 | deriving (Show, Generic, Eq) 73 | 74 | type EntryId = Int 75 | 76 | type Entry = Entry' EntryId EntryInfo 77 | 78 | data Entry' id entry = 79 | Entry 80 | { _entryId :: !id 81 | , _entryEntry :: !entry 82 | } deriving (Show, Generic, Eq) 83 | 84 | type EntryInfo = EntryInfo' T.Text Bool 85 | 86 | data EntryInfo' description completed = 87 | EntryInfo 88 | { _entryInfDescription :: !description 89 | , _entryInfCompleted :: !completed 90 | } deriving (Show, Generic, Eq) 91 | 92 | instance ToJSON EntryEvent 93 | 94 | instance FromJSON EntryEvent 95 | 96 | instance ToJSON Entry where 97 | toJSON = genericToJSON $ optionsDelPrefix "_entry" 98 | 99 | instance FromJSON Entry where 100 | parseJSON = genericParseJSON $ optionsDelPrefix "_entry" 101 | 102 | instance ToJSON EntryInfo where 103 | toJSON = genericToJSON $ optionsDelPrefix "_entryInf" 104 | 105 | instance FromJSON EntryInfo where 106 | parseJSON = genericParseJSON $ optionsDelPrefix "_entryInf" 107 | 108 | 109 | -------------------------------------------------------------------------------- 110 | -- Utilities 111 | -------------------------------------------------------------------------------- 112 | 113 | optionsDelPrefix :: String -> Options 114 | optionsDelPrefix prefix = 115 | defaultOptions{fieldLabelModifier = delPrefix prefix} 116 | 117 | delPrefix :: String -> (String -> String) 118 | delPrefix "" = id 119 | delPrefix prefix = \fieldName -> 120 |     case stripPrefix prefix fieldName of 121 |       Just (c:cs) 122 |           | isUpper c -> toLower c : cs 123 |           | otherwise -> error $ "The field name after the prefix " 124 | ++ "must be written in CamelCase" 125 |       Just "" -> error $ "The field name after the prefix may not be empty" 126 |       Nothing -> error $ "The field name " ++ quotes fieldName 127 | ++ " does not begin with the required prefix " 128 | ++ quotes prefix 129 | 130 | quotes :: String -> String 131 | quotes s = "\"" ++ s ++ "\"" 132 | 133 | makeLenses ''Entry' 134 | makeLenses ''EntryInfo' 135 | 136 | makeAdaptorAndInstance "pEntry" ''Entry' 137 | makeAdaptorAndInstance "pEntryInfo" ''EntryInfo' 138 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/nixtodo-backend.cabal: -------------------------------------------------------------------------------- 1 | name: nixtodo-backend 2 | version: 0.1.0.0 3 | synopsis: TODO-list backend 4 | homepage: https://github.com/basvandijk/nix-workshop 5 | license: BSD3 6 | author: Bas van Dijk 7 | maintainer: v.dijk.bas@gmail.com 8 | build-type: Simple 9 | extra-source-files: ChangeLog.md 10 | cabal-version: >=1.10 11 | 12 | executable nixtodo-backend 13 | main-is: nixtodo-backend.hs 14 | build-depends: SHA 15 | , async 16 | , aeson 17 | , base 18 | , base16-bytestring 19 | , bytestring 20 | , containers 21 | , configurator 22 | , data-default 23 | , directory 24 | , filepath 25 | , fsnotify 26 | , hastache 27 | , http-types 28 | , lens 29 | , managed 30 | , opaleye 31 | , optparse-applicative 32 | , postgresql-simple 33 | , product-profunctors 34 | , resource-pool 35 | , servant 36 | , servant-server 37 | , stm 38 | , text 39 | , tagged 40 | , nixtodo-api 41 | , unix 42 | , wai-extra 43 | , wai-app-static 44 | , wai-websockets 45 | , websockets 46 | , wai 47 | , warp 48 | , zlib 49 | default-language: Haskell2010 50 | hs-source-dirs: src 51 | other-modules: 52 | Nixtodo.Backend.Db 53 | Nixtodo.Backend.Db.Types 54 | Nixtodo.Backend.WebServer 55 | Nixtodo.Backend.IndexTemplater 56 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/shell.nix: -------------------------------------------------------------------------------- 1 | (import ../..).haskellPackages.nixtodo-backend.env 2 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/src/Nixtodo/Backend/Db.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE PackageImports #-} 3 | {-# LANGUAGE Arrows #-} 4 | 5 | module Nixtodo.Backend.Db 6 | ( -- * Configuration 7 | Config(..) 8 | , PoolConfig(..) 9 | , parseConfig 10 | 11 | -- * Initialization 12 | , Handle 13 | , with 14 | 15 | -- * API 16 | , createEntry 17 | , readEntries 18 | , updateEntry 19 | , deleteEntry 20 | 21 | -- * Events 22 | , EventListener 23 | , getEventListener 24 | , getEvent 25 | ) where 26 | 27 | import "async" Control.Concurrent.Async (withAsyncWithUnmask) 28 | import "base" Control.Arrow (returnA) 29 | import "base" Control.Monad (forever) 30 | import "base" Control.Monad.IO.Class (liftIO) 31 | import "base" Control.Monad (void) 32 | import "base" Data.Foldable (for_) 33 | import qualified "bytestring" Data.ByteString.Char8 as BC8 34 | import qualified "configurator" Data.Configurator as C 35 | import qualified "configurator" Data.Configurator.Types as C 36 | import "lens" Control.Lens 37 | import "managed" Control.Monad.Managed.Safe ( Managed, managed ) 38 | import "nixtodo-api" Nixtodo.Api 39 | import "opaleye" Opaleye 40 | import qualified "postgresql-simple" Database.PostgreSQL.Simple as Pg 41 | import "postgresql-simple" Database.PostgreSQL.Simple.Notification 42 | import qualified "resource-pool" Data.Pool as Pool 43 | import "resource-pool" Data.Pool (Pool, LocalPool, withResource) 44 | import "stm" Control.Concurrent.STM.TChan 45 | import "stm" Control.Monad.STM (atomically) 46 | import qualified "text" Data.Text as T 47 | import qualified "text" Data.Text.IO as T (readFile) 48 | import "this" Nixtodo.Backend.Db.Types 49 | 50 | 51 | -------------------------------------------------------------------------------- 52 | -- Configuration 53 | -------------------------------------------------------------------------------- 54 | 55 | data Config 56 | = Config 57 | { cfgPoolConfig :: !PoolConfig 58 | , cfgConnectInfo :: !Pg.ConnectInfo 59 | } 60 | 61 | data PoolConfig 62 | = PoolConfig 63 | { poolCfgNumStripes :: !Int 64 | , poolCfgMaxResources :: !Int 65 | , poolCfgIdleTime :: !Integer 66 | } 67 | 68 | parseConfig :: C.Config -> IO Config 69 | parseConfig cfg = 70 | Config <$> parsePoolConfig (C.subconfig "pool" cfg) 71 | <*> parseConnectInfoConfig cfg 72 | where 73 | parsePoolConfig :: C.Config -> IO PoolConfig 74 | parsePoolConfig cfg = 75 | PoolConfig <$> C.require cfg "numStripes" 76 | <*> C.require cfg "maxResources" 77 | <*> C.require cfg "idleTime" 78 | 79 | parseConnectInfoConfig :: C.Config -> IO Pg.ConnectInfo 80 | parseConnectInfoConfig cfg = do 81 | host <- C.require cfg "host" 82 | port <- read <$> C.require cfg "port" 83 | user <- C.require cfg "user" 84 | passwordFile <- C.require cfg "passwordFile" 85 | database <- C.require cfg "database" 86 | 87 | password <- T.readFile passwordFile 88 | 89 | return Pg.ConnectInfo 90 | { Pg.connectHost = host 91 | , Pg.connectPort = port 92 | , Pg.connectUser = user 93 | , Pg.connectPassword = T.unpack password 94 | , Pg.connectDatabase = database 95 | } 96 | 97 | 98 | -------------------------------------------------------------------------------- 99 | -- Initialization 100 | -------------------------------------------------------------------------------- 101 | 102 | data Handle = 103 | Handle 104 | { hndlPgConnPool :: !(Pool Pg.Connection) 105 | , hndlEventChan :: !(TChan EntryEvent) 106 | } 107 | 108 | with :: Config -> Managed Handle 109 | with config = do 110 | pgConnPool <- liftIO $ Pool.createPool 111 | (Pg.connect $ cfgConnectInfo config) 112 | Pg.close 113 | (poolCfgNumStripes poolConfig) 114 | (fromIntegral $ poolCfgIdleTime poolConfig) 115 | (poolCfgMaxResources poolConfig) 116 | 117 | eventChan <- liftIO $ newBroadcastTChanIO 118 | 119 | let hndl = Handle 120 | { hndlPgConnPool = pgConnPool 121 | , hndlEventChan = eventChan 122 | } 123 | 124 | _async <- managed $ withAsyncWithUnmask $ \unmask -> 125 | unmask $ forwardEvents hndl 126 | 127 | pure hndl 128 | where 129 | poolConfig = cfgPoolConfig config 130 | 131 | withConnection :: Handle -> (Pg.Connection -> IO a) -> IO a 132 | withConnection hndl = withResource (hndlPgConnPool hndl) 133 | 134 | -- TODO: When an exception occurs, log it and restart the thread! 135 | forwardEvents :: Handle -> IO () 136 | forwardEvents hndl = withConnection hndl $ \conn -> do 137 | void $ Pg.execute_ conn "LISTEN event_channel" 138 | forever $ do 139 | not <- getNotification conn 140 | 141 | -- For debugging 142 | print $ notificationData not 143 | 144 | for_ (parseNotificationData $ notificationData not) $ \(operation, eid) -> 145 | case operation of 146 | DELETE -> atomically $ writeTChan eventChan $ DeleteEntryEvent eid 147 | UPSERT -> do 148 | mbEntry <- lookupEntry hndl eid 149 | for_ mbEntry $ \entry -> 150 | atomically $ writeTChan eventChan $ UpsertEntryEvent entry 151 | where 152 | eventChan = hndlEventChan hndl 153 | 154 | data Operation = UPSERT | DELETE 155 | 156 | parseNotificationData :: BC8.ByteString -> Maybe (Operation, EntryId) 157 | parseNotificationData bs = do 158 | let (operationBs, restBs) = BC8.break (== ':') bs 159 | operation <- case operationBs of 160 | "INSERT" -> Just UPSERT 161 | "UPDATE" -> Just UPSERT 162 | "DELETE" -> Just DELETE 163 | _ -> Nothing 164 | (':', eidBs) <- BC8.uncons restBs 165 | (eid, "") <- BC8.readInt eidBs 166 | pure (operation, eid) 167 | 168 | 169 | -------------------------------------------------------------------------------- 170 | -- API 171 | -------------------------------------------------------------------------------- 172 | 173 | createEntry :: Handle -> EntryInfo -> IO Entry 174 | createEntry hndl entryInf = do 175 | entries <- withConnection hndl $ \conn -> 176 | runInsertReturning conn entriesTable 177 | Entry{ _entryId = Nothing 178 | , _entryEntry = 179 | EntryInfo 180 | { _entryInfDescription = constant $ entryInf ^. entryInfDescription 181 | , _entryInfCompleted = constant $ entryInf ^. entryInfCompleted 182 | } 183 | } 184 | id 185 | case entries of 186 | [entry] -> pure entry 187 | _ -> error "todo" 188 | 189 | readEntries :: Handle -> IO [Entry] 190 | readEntries hndl = 191 | withConnection hndl $ \conn -> runQuery conn $ 192 | queryTable entriesTable 193 | 194 | lookupEntry :: Handle -> EntryId -> IO (Maybe Entry) 195 | lookupEntry hndl eid = do 196 | entries <- withConnection hndl $ \conn -> runQuery conn $ proc () -> do 197 | entry <- queryTable entriesTable -< () 198 | restrict -< entry ^. entryId .=== constant eid 199 | returnA -< entry 200 | case entries of 201 | [entry] -> pure $ Just entry 202 | [] -> pure Nothing 203 | _ -> error "Multiple rows returned!" 204 | 205 | updateEntry :: Handle -> EntryId -> EntryInfo -> IO () 206 | updateEntry hndl eid entryInf = 207 | void $ withConnection hndl $ \conn -> 208 | runUpdate conn entriesTable 209 | (\_entry -> Entry 210 | { _entryId = Nothing -- Just $ constant eid 211 | , _entryEntry = constant entryInf 212 | }) 213 | (\entry -> entry ^. entryId .=== constant eid) 214 | 215 | deleteEntry :: Handle -> EntryId -> IO () 216 | deleteEntry hndl eid = 217 | void $ withConnection hndl $ \conn -> 218 | runDelete conn entriesTable $ \entry -> 219 | entry ^. entryId .=== constant eid 220 | 221 | 222 | -------------------------------------------------------------------------------- 223 | -- Events 224 | -------------------------------------------------------------------------------- 225 | 226 | newtype EventListener = EventListener (TChan EntryEvent) 227 | 228 | getEventListener :: Handle -> IO EventListener 229 | getEventListener hndl = fmap EventListener $ atomically $ 230 | dupTChan $ hndlEventChan hndl 231 | 232 | getEvent :: EventListener -> IO EntryEvent 233 | getEvent (EventListener eventChan) = atomically $ readTChan eventChan 234 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/src/Nixtodo/Backend/Db/Types.hs: -------------------------------------------------------------------------------- 1 | {-# language TemplateHaskell #-} 2 | {-# language OverloadedStrings #-} 3 | {-# language FlexibleInstances #-} 4 | {-# language MultiParamTypeClasses #-} 5 | {-# language PackageImports #-} 6 | 7 | module Nixtodo.Backend.Db.Types where 8 | 9 | import "opaleye" Opaleye 10 | import "product-profunctors" Data.Profunctor.Product.TH ( makeAdaptorAndInstance ) 11 | import "lens" Control.Lens ( makeLenses ) 12 | import qualified "text" Data.Text as T 13 | import "nixtodo-api" Nixtodo.Api 14 | 15 | type DBEntryInfo = EntryInfo' 16 | (Column PGText) 17 | (Column PGBool) 18 | 19 | entriesTable :: Table (Entry' (Maybe (Column PGInt4)) DBEntryInfo) 20 | (Entry' (Column PGInt4) DBEntryInfo) 21 | entriesTable = 22 | Table "entries" $ 23 | pEntry Entry 24 | { _entryId = optional "id" 25 | , _entryEntry = 26 | pEntryInfo EntryInfo 27 | { _entryInfDescription = required "description" 28 | , _entryInfCompleted = required "completed" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/src/Nixtodo/Backend/IndexTemplater.hs: -------------------------------------------------------------------------------- 1 | {-# language PackageImports #-} 2 | {-# language OverloadedStrings #-} 3 | 4 | module Nixtodo.Backend.IndexTemplater 5 | ( Config(..) 6 | , parseConfig 7 | 8 | , Handle 9 | , getConfig 10 | , with 11 | 12 | , getHashed 13 | , getClient 14 | ) where 15 | 16 | import qualified "SHA" Data.Digest.Pure.SHA as SHA 17 | import "base" Control.Concurrent.MVar (MVar, newMVar, withMVar) 18 | import "base" Control.Monad (when) 19 | import "base" Control.Monad.IO.Class (liftIO) 20 | import "base" Data.Functor (void) 21 | import "base" Data.Monoid ((<>)) 22 | import "base16-bytestring" Data.ByteString.Base16.Lazy as Base16L 23 | import qualified "bytestring" Data.ByteString.Lazy as BL 24 | import qualified "bytestring" Data.ByteString.Lazy.Char8 as BLC8 25 | import qualified "configurator" Data.Configurator as C 26 | import qualified "configurator" Data.Configurator.Types as C 27 | import qualified "directory" System.Directory as Dir 28 | import "filepath" System.FilePath ( (<.>), () ) 29 | import qualified "filepath" System.FilePath as Fp 30 | import qualified "fsnotify" System.FSNotify as FSNotify 31 | import qualified "hastache" Text.Hastache as H 32 | import qualified "hastache" Text.Hastache.Context as H 33 | import qualified "http-types" Network.HTTP.Types.Status as Http 34 | import "managed" Control.Monad.Managed.Safe ( Managed, managed ) 35 | import qualified "servant-server" Servant 36 | import "tagged" Data.Tagged (Tagged(..)) 37 | import qualified "text" Data.Text as T 38 | import qualified "text" Data.Text.Lazy.IO as TL 39 | import qualified "unix" System.Posix.Files as Posix 40 | import qualified "wai" Network.Wai as Wai 41 | import "wai-app-static" Network.Wai.Application.Static ( StaticSettings, defaultFileServerSettings, staticApp, ssMaxAge ) 42 | import "wai-app-static" WaiAppStatic.Types ( MaxAge(MaxAgeForever) ) 43 | import qualified "zlib" Codec.Compression.GZip as Gz 44 | 45 | data Config 46 | = Config 47 | { cfgIndexTemplatePath :: !FilePath 48 | , cfgSrcDir :: !FilePath 49 | , cfgDstDir :: !FilePath 50 | , cfgUrlPrefix :: !FilePath 51 | , cfgCompressLevel :: !Int 52 | } 53 | 54 | parseConfig :: C.Config -> IO Config 55 | parseConfig cfg = Config <$> C.require cfg "indexTemplatePath" 56 | <*> C.require cfg "srcDir" 57 | <*> C.require cfg "dstDir" 58 | <*> C.require cfg "urlPrefix" 59 | <*> C.require cfg "compressLevel" 60 | 61 | data Handle 62 | = Handle 63 | { hndlConfig :: !Config 64 | , hndlLock :: !(MVar ()) 65 | } 66 | 67 | getConfig :: Handle -> Config 68 | getConfig = hndlConfig 69 | 70 | with :: Config -> Managed Handle 71 | with cfg = do 72 | watchManager <- managed FSNotify.withManager 73 | 74 | hndl <- liftIO initHndl 75 | 76 | liftIO $ do 77 | update hndl 78 | 79 | void $ FSNotify.watchTree 80 | watchManager 81 | (cfgSrcDir cfg) 82 | shouldHandleFsEvent 83 | (\_ -> update hndl) 84 | 85 | pure hndl 86 | where 87 | initHndl :: IO Handle 88 | initHndl = do 89 | lock <- newMVar () 90 | pure Handle 91 | { hndlConfig = cfg 92 | , hndlLock = lock 93 | } 94 | 95 | shouldHandleFsEvent :: FSNotify.Event -> Bool 96 | shouldHandleFsEvent FSNotify.Added{} = False 97 | shouldHandleFsEvent FSNotify.Modified{} = True 98 | shouldHandleFsEvent FSNotify.Removed{} = False 99 | 100 | update :: Handle -> IO () 101 | update hndl = withMVar lock $ \_ -> do 102 | currentExists <- Dir.doesDirectoryExist currentLinkFp 103 | 104 | newRelative <- if currentExists 105 | then otherThen <$> Posix.readSymbolicLink currentLinkFp 106 | else pure "a" 107 | 108 | let new = dstDir newRelative 109 | 110 | newExists <- Dir.doesDirectoryExist new 111 | when newExists $ Dir.removeDirectoryRecursive new 112 | Dir.createDirectoryIfMissing True new 113 | 114 | let muContext :: H.MuContext IO 115 | muContext = H.mkStrContextM $ \fp -> do 116 | let fullFp = cfgSrcDir cfg fp 117 | 118 | bytes <- BL.readFile fullFp 119 | 120 | let compressed = Gz.compressWith compressParams bytes 121 | hash = sha256sum compressed 122 | 123 | hashFpExt = hash <> "-" <> Fp.takeFileName fp 124 | srcFp = new hashFpExt 125 | srcFpGz = srcFp <.> "gz" 126 | 127 | fullAbsFp <- Dir.makeAbsolute fullFp 128 | Posix.createSymbolicLink fullAbsFp srcFp 129 | BL.writeFile srcFpGz compressed 130 | 131 | pure $ H.MuVariable $ cfgUrlPrefix cfg hashFpExt 132 | 133 | indexTxt <- H.hastacheFile H.defaultConfig (cfgIndexTemplatePath cfg) muContext 134 | 135 | TL.writeFile (new indexFp) indexTxt 136 | 137 | newCurrentLinkExists <- Dir.doesDirectoryExist newCurrentLinkFp 138 | when newCurrentLinkExists $ Posix.removeLink newCurrentLinkFp 139 | Posix.createSymbolicLink newRelative newCurrentLinkFp 140 | 141 | Dir.renameFile newCurrentLinkFp currentLinkFp 142 | where 143 | currentLinkFp, newCurrentLinkFp :: FilePath 144 | currentLinkFp = dstDir currentFp 145 | newCurrentLinkFp = currentLinkFp <.> "new" 146 | 147 | otherThen :: FilePath -> FilePath 148 | otherThen "a" = "b" 149 | otherThen "b" = "a" 150 | otherThen _ = error "Invalid current link!" 151 | 152 | dstDir = cfgDstDir cfg 153 | 154 | compressParams :: Gz.CompressParams 155 | compressParams = Gz.defaultCompressParams 156 | { Gz.compressLevel = Gz.compressionLevel $ cfgCompressLevel cfg 157 | , Gz.compressMemoryLevel = Gz.maxMemoryLevel 158 | } 159 | 160 | cfg = hndlConfig hndl 161 | lock = hndlLock hndl 162 | 163 | sha256sum :: BL.ByteString -> String 164 | sha256sum = BLC8.unpack 165 | . BL.take 10 166 | . Base16L.encode 167 | . SHA.bytestringDigest 168 | . SHA.sha256 169 | 170 | getHashed :: Handle -> Servant.Server Servant.Raw 171 | getHashed hndl = Servant.Tagged $ staticApp staticSettings 172 | where 173 | staticSettings :: StaticSettings 174 | staticSettings = (defaultFileServerSettings 175 | (Fp.addTrailingPathSeparator 176 | (cfgDstDir cfg currentFp))) 177 | { ssMaxAge = MaxAgeForever } 178 | 179 | cfg = hndlConfig hndl 180 | 181 | getClient :: Handle -> Servant.Server Servant.Raw 182 | getClient hndl = Servant.Tagged $ requireEmptyPath $ 183 | \_req respond -> do 184 | respond $ Wai.responseFile 185 | Http.ok200 186 | [ ("Cache-Control", "no-cache, no-store, must-revalidate") 187 | , ("Expires", "0") 188 | ] 189 | (cfgDstDir (hndlConfig hndl) currentFp indexFp) 190 | Nothing 191 | 192 | currentFp :: FilePath 193 | currentFp = "current" 194 | 195 | indexFp :: FilePath 196 | indexFp = "index.html" 197 | 198 | requireEmptyPath :: Wai.Middleware 199 | requireEmptyPath application = 200 | \req respond -> 201 | case Wai.pathInfo req of 202 | [] -> application req respond 203 | _ -> respond $ Wai.responseLBS Http.notFound404 [] "not found" 204 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/src/Nixtodo/Backend/WebServer.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings #-} 2 | {-# language TypeApplications #-} 3 | {-# language PackageImports #-} 4 | 5 | module Nixtodo.Backend.WebServer 6 | ( -- * Configuration 7 | Config(..) 8 | , parseConfig 9 | 10 | -- * Serving 11 | , serve 12 | ) where 13 | 14 | import qualified "aeson" Data.Aeson as Json (encode) 15 | import "base" Control.Monad (forever) 16 | import "base" Control.Monad.IO.Class (liftIO) 17 | import "base" Data.Proxy (Proxy(Proxy)) 18 | import "base" Data.String (fromString) 19 | import qualified "configurator" Data.Configurator as C 20 | import qualified "configurator" Data.Configurator.Types as C 21 | import "data-default" Data.Default (def) 22 | import "filepath" System.FilePath ( addTrailingPathSeparator ) 23 | import qualified "http-types" Network.HTTP.Types.Status as Http 24 | import "nixtodo-api" Nixtodo.Api 25 | import "servant" Servant.API 26 | import qualified "servant-server" Servant 27 | import "tagged" Data.Tagged (Tagged(..)) 28 | import qualified "this" Nixtodo.Backend.Db as Db 29 | import qualified "this" Nixtodo.Backend.IndexTemplater as IndexTemplater 30 | import qualified "wai" Network.Wai as Wai 31 | import "wai-app-static" Network.Wai.Application.Static ( StaticSettings, ssMaxAge, defaultFileServerSettings, staticApp ) 32 | import "wai-app-static" WaiAppStatic.Types ( MaxAge(MaxAgeForever) ) 33 | import "wai-extra" Network.Wai.Middleware.Gzip ( gzip, gzipFiles, GzipFiles(..) ) 34 | import qualified "wai-websockets" Network.Wai.Handler.WebSockets as WebSockets 35 | import qualified "warp" Network.Wai.Handler.Warp as Warp 36 | import qualified "websockets" Network.WebSockets.Connection as WebSockets 37 | 38 | 39 | -------------------------------------------------------------------------------- 40 | -- Configuration 41 | -------------------------------------------------------------------------------- 42 | 43 | data Config = Config { cfgWarpSettings :: !Warp.Settings } 44 | 45 | parseConfig 46 | :: C.Config 47 | -> IO Config 48 | parseConfig cfg = do 49 | warpSettings <- mkWarpSettings cfg 50 | pure Config{ cfgWarpSettings = warpSettings } 51 | where 52 | mkWarpSettings :: C.Config -> IO Warp.Settings 53 | mkWarpSettings cfg = do 54 | port <- read <$> C.require cfg "port" 55 | host <- C.require cfg "host" 56 | pure $ Warp.setPort port 57 | $ Warp.setHost (fromString host) 58 | $ Warp.defaultSettings 59 | 60 | 61 | -------------------------------------------------------------------------------- 62 | -- Serving 63 | -------------------------------------------------------------------------------- 64 | 65 | serve :: Config -> Db.Handle -> IndexTemplater.Handle -> IO () 66 | serve cfg db frontendIndexTemplater = 67 | Warp.runSettings (cfgWarpSettings cfg) $ 68 | gzip gzipSettings $ 69 | Servant.serve (Proxy @NixtodoApi) todoServer 70 | where 71 | gzipSettings = def{gzipFiles = GzipPreCompressed GzipCompress} 72 | 73 | todoServer :: Servant.Server NixtodoApi 74 | todoServer = 75 | ( createEntryServer 76 | :<|> readEntriesServer 77 | :<|> updateEntryServer 78 | :<|> deleteEntryServer 79 | ) :<|> websocketServer 80 | :<|> frontendServer 81 | 82 | createEntryServer :: Servant.Server CreateEntry 83 | createEntryServer entryInfo = do 84 | liftIO $ Db.createEntry db entryInfo 85 | 86 | readEntriesServer :: Servant.Server ReadEntries 87 | readEntriesServer = do 88 | liftIO $ Db.readEntries db 89 | 90 | updateEntryServer :: Servant.Server UpdateEntry 91 | updateEntryServer entryId entryInfo = do 92 | liftIO $ Db.updateEntry db entryId entryInfo 93 | pure NoContent 94 | 95 | deleteEntryServer :: Servant.Server DeleteEntry 96 | deleteEntryServer entryId = do 97 | liftIO $ Db.deleteEntry db entryId 98 | pure NoContent 99 | 100 | websocketServer :: Servant.Server Raw 101 | websocketServer = 102 | Tagged $ WebSockets.websocketsOr opts listener nonSocket 103 | where 104 | nonSocket :: Wai.Application 105 | nonSocket _req respond = 106 | respond $ Wai.responseLBS 107 | Http.notAcceptable406 108 | [("Content-Type", "text/plain")] 109 | "This is a websocket route. Connect to it using websockets." 110 | 111 | opts = WebSockets.ConnectionOptions $ pure () 112 | 113 | listener :: WebSockets.PendingConnection -> IO () 114 | listener pendingConn = do 115 | conn <- WebSockets.acceptRequest pendingConn 116 | listener <- Db.getEventListener db 117 | forever $ do 118 | event <- Db.getEvent listener 119 | WebSockets.sendTextData conn $ Json.encode event 120 | 121 | frontendServer :: Servant.Server FrontendApi 122 | frontendServer = 123 | serveStatic frontendIndexTemplater 124 | :<|> IndexTemplater.getHashed frontendIndexTemplater 125 | :<|> IndexTemplater.getClient frontendIndexTemplater 126 | where 127 | serveStatic :: IndexTemplater.Handle -> Servant.Server GetStatic 128 | serveStatic indexTemplater = 129 | Tagged $ staticApp $ 130 | (defaultFileServerSettings 131 | (addTrailingPathSeparator 132 | (IndexTemplater.cfgSrcDir 133 | (IndexTemplater.getConfig indexTemplater)))) 134 | { ssMaxAge = MaxAgeForever } 135 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-backend/src/nixtodo-backend.hs: -------------------------------------------------------------------------------- 1 | {-# language OverloadedStrings #-} 2 | {-# language PackageImports #-} 3 | 4 | module Main (main) where 5 | 6 | import "base" Control.Applicative (many) 7 | import "base" Control.Monad.IO.Class (liftIO) 8 | import "base" Data.Monoid ((<>)) 9 | import qualified "configurator" Data.Configurator as C 10 | import qualified "configurator" Data.Configurator.Types as C 11 | import "managed" Control.Monad.Managed.Safe (runManaged, managed_) 12 | import qualified "optparse-applicative" Options.Applicative as O 13 | import qualified "text" Data.Text as T 14 | import qualified "this" Nixtodo.Backend.Db as Db 15 | import qualified "this" Nixtodo.Backend.IndexTemplater as IndexTemplater 16 | import qualified "this" Nixtodo.Backend.WebServer as WebServer 17 | 18 | main :: IO () 19 | main = runManaged $ do 20 | cfg <- liftIO $ do 21 | configFiles <- O.execParser opts 22 | C.load $ map C.Required configFiles 23 | 24 | let subCfg :: T.Text -> C.Config 25 | subCfg sectionName = C.subconfig sectionName cfg 26 | 27 | db <- do 28 | c <- liftIO $ Db.parseConfig (subCfg "db") 29 | Db.with c 30 | 31 | frontendIndexTemplater <- do 32 | c <- liftIO $ IndexTemplater.parseConfig $ subCfg "frontendIndexTemplater" 33 | IndexTemplater.with c 34 | 35 | liftIO $ do 36 | c <- WebServer.parseConfig (subCfg "web-server") 37 | WebServer.serve c db frontendIndexTemplater 38 | where 39 | opts :: O.ParserInfo [FilePath] 40 | opts = O.info (O.helper <*> options) 41 | ( O.fullDesc 42 | <> O.progDesc "The TODO-list backend server" 43 | ) 44 | 45 | options :: O.Parser [FilePath] 46 | options = many (O.strOption ( O.long "config" 47 | <> O.short 'c' 48 | <> O.help "Configuration files to load" 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/cabal.config: -------------------------------------------------------------------------------- 1 | compiler: ghcjs -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/index.html.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/nixtodo-frontend.cabal: -------------------------------------------------------------------------------- 1 | name: nixtodo-frontend 2 | version: 0.1.0.0 3 | synopsis: GHCJS TODO-list frontend web-app 4 | homepage: https://github.com/basvandijk/nix-workshop 5 | license: BSD3 6 | author: Bas van Dijk 7 | maintainer: v.dijk.bas@gmail.com 8 | build-type: Simple 9 | extra-source-files: ChangeLog.md 10 | cabal-version: >=1.10 11 | 12 | executable nixtodo-frontend 13 | main-is: nixtodo-frontend.hs 14 | hs-source-dirs: src 15 | other-modules: Servant.Client.Ghcjs.Extended 16 | build-depends: base 17 | , containers 18 | , miso 19 | , aeson 20 | , ghcjs-base 21 | , lens 22 | , mtl 23 | , text 24 | , transformers 25 | , nixtodo-api-client 26 | , nixtodo-api 27 | , servant-client-ghcjs 28 | , ghcjs-dom-jsffi 29 | default-language: Haskell2010 30 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/override.nix: -------------------------------------------------------------------------------- 1 | pkgs : drv : with pkgs.haskell.lib; 2 | overrideCabal drv (drv : { 3 | postInstall = '' 4 | mkdir -p $out/static 5 | ln -s ${./static/style.css} $out/static/style.css 6 | ln -s ${./static/default.css} $out/static/default.css 7 | ln -s ${./static/favicon.ico} $out/static/favicon.ico 8 | ln -s ${./static/haskell.png} $out/static/haskell.png 9 | ln -s ${./static/nix.png} $out/static/nix.png 10 | ln -s ${./index.html.mustache} $out/index.html.mustache 11 | ''; 12 | }) 13 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/shell.nix: -------------------------------------------------------------------------------- 1 | (import ).haskell.packages.ghcjsHEAD.nixtodo-frontend.env 2 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/src/Servant/Client/Ghcjs/Extended.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE PackageImports #-} 3 | 4 | -- | Exports a function to make client-side AJAX requests to the server that 5 | -- provides the client. 6 | module Servant.Client.Ghcjs.Extended 7 | ( callServant 8 | ) where 9 | 10 | import qualified "ghcjs-base" Data.JSString as JSS 11 | import "ghcjs-base" JavaScript.Web.Location 12 | import "servant-client-ghcjs" Servant.Client.Ghcjs 13 | 14 | -- | Performs blocking AJAX request on the location of the browser window 15 | callServant 16 | :: String 17 | -- ^ Path prefixed to HTTP requests. 18 | -> ClientM a 19 | -> IO (Either ServantError a) 20 | callServant path m = do 21 | curLoc <- getWindowLocation 22 | 23 | jsStr_protocol <- getProtocol curLoc 24 | jsStr_port <- getPort curLoc 25 | jsStr_hostname <- getHostname curLoc 26 | 27 | let protocol 28 | | jsStr_protocol == "https:" = Https 29 | | otherwise = Http 30 | 31 | portStr :: String 32 | portStr = JSS.unpack jsStr_port 33 | 34 | port :: Int 35 | port | null portStr = case protocol of 36 | Http -> 80 37 | Https -> 443 38 | | otherwise = read portStr 39 | 40 | hostname :: String 41 | hostname = JSS.unpack jsStr_hostname 42 | 43 | runClientM m (ClientEnv (BaseUrl protocol hostname port path)) 44 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/src/nixtodo-frontend.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TypeOperators #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE FlexibleInstances #-} 4 | {-# LANGUAGE TypeFamilies #-} 5 | {-# LANGUAGE DataKinds #-} 6 | {-# LANGUAGE DeriveGeneric #-} 7 | {-# LANGUAGE ScopedTypeVariables #-} 8 | {-# LANGUAGE RecordWildCards #-} 9 | {-# LANGUAGE LambdaCase #-} 10 | {-# LANGUAGE MultiParamTypeClasses #-} 11 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 12 | {-# LANGUAGE ExtendedDefaultRules #-} 13 | {-# LANGUAGE TemplateHaskell #-} 14 | {-# LANGUAGE NumDecimals #-} 15 | {-# LANGUAGE PackageImports #-} 16 | 17 | module Main where 18 | 19 | import "aeson" Data.Aeson hiding (Object, (.=)) 20 | import "base" Control.Concurrent (threadDelay) 21 | import "base" Control.Monad 22 | import "base" Data.Bool 23 | import qualified "base" Data.Foldable as F (for_) 24 | import "base" Data.Function (on) 25 | import "base" Data.List 26 | import "base" Data.Maybe (listToMaybe) 27 | import "base" Data.Monoid 28 | import "base" GHC.Generics (Generic) 29 | import qualified "containers" Data.Map as M 30 | import qualified "ghcjs-base" Data.JSString as JSS 31 | import "ghcjs-base" Data.JSString.Text (textFromJSString) 32 | import qualified "ghcjs-base" JavaScript.Web.Location 33 | import "ghcjs-dom-jsffi" GHCJS.DOM (currentWindowUnchecked) 34 | import "ghcjs-dom-jsffi" GHCJS.DOM.Location (getProtocol, getHostname, getPort) 35 | import "ghcjs-dom-jsffi" GHCJS.DOM.Window (getLocation) 36 | import qualified "lens" Control.Lens as L 37 | import "lens" Control.Lens hiding (view) 38 | import "miso" Miso hiding (on) 39 | import "miso" Miso.String (MisoString, toMisoString) 40 | import qualified "miso" Miso.String as S 41 | import "nixtodo-api" Nixtodo.Api 42 | import "nixtodo-api-client" Nixtodo.Api.Client 43 | import "servant-client-ghcjs" Servant.Client.Ghcjs 44 | import qualified "text" Data.Text as T 45 | import "this" Servant.Client.Ghcjs.Extended (callServant) 46 | import "transformers" Control.Monad.Trans.State.Strict 47 | 48 | 49 | -------------------------------------------------------------------------------- 50 | -- Model 51 | -------------------------------------------------------------------------------- 52 | 53 | data Model = Model 54 | { _entryRecords :: ![EntryRecord] 55 | , _pendingEntryRecords :: ![EntryRecord] 56 | , _field :: !T.Text 57 | , _uid :: !Int 58 | , _visibility :: !Visibility 59 | } deriving (Show, Generic, Eq) 60 | 61 | data EntryRecord = EntryRecord 62 | { _entryRecEntry :: !Entry 63 | , _entryRecEditing :: !Bool 64 | , _entryRecFocussed :: !Bool 65 | } deriving (Show, Generic, Eq) 66 | 67 | data Visibility = ViewAll | ViewActive | ViewCompleted 68 | deriving (Show, Generic, Eq) 69 | 70 | makeLenses ''Model 71 | makeLenses ''EntryRecord 72 | 73 | instance ToJSON EntryRecord 74 | instance ToJSON Model 75 | instance ToJSON Visibility 76 | 77 | instance FromJSON EntryRecord 78 | instance FromJSON Model 79 | instance FromJSON Visibility 80 | 81 | eid :: Lens' EntryRecord EntryId 82 | description :: Lens' EntryRecord T.Text 83 | completed :: Lens' EntryRecord Bool 84 | eid = entryRecEntry . entryId 85 | description = entryRecEntry . entryEntry . entryInfDescription 86 | completed = entryRecEntry . entryEntry . entryInfCompleted 87 | 88 | 89 | -------------------------------------------------------------------------------- 90 | -- View 91 | -------------------------------------------------------------------------------- 92 | 93 | viewModel :: Model -> View Action 94 | viewModel m = 95 | div_ 96 | [ class_ "todomvc-wrapper" 97 | , style_ $ M.singleton "visibility" "hidden" 98 | ] 99 | [ section_ 100 | [ class_ "todoapp" ] 101 | [ viewInput m (m ^. field) 102 | , viewEntries (m ^. visibility) sortedEntries sortedPendingEntries 103 | , viewControls m (m ^. visibility) sortedEntries 104 | ] 105 | , infoFooter 106 | ] 107 | where 108 | sortedEntries, sortedPendingEntries :: [EntryRecord] 109 | sortedEntries = sortEntries (m ^. entryRecords) 110 | sortedPendingEntries = sortEntries (m ^. pendingEntryRecords) 111 | 112 | sortEntries = sortBy (compare `on` L.view eid) 113 | 114 | viewEntries :: Visibility -> [ EntryRecord ] -> [ EntryRecord ] -> View Action 115 | viewEntries visibility entries pendingEntries = 116 | section_ 117 | [ class_ "main" 118 | , style_ $ M.singleton "visibility" cssVisibility 119 | ] 120 | [ input_ 121 | [ class_ "toggle-all" 122 | , type_ "checkbox" 123 | , name_ "toggle" 124 | , checked_ allCompleted 125 | , onClick $ CheckAll (not allCompleted) 126 | ] [] 127 | , label_ 128 | [ for_ "toggle-all" ] 129 | [ text $ S.pack "Mark all as complete" ] 130 | , ul_ [ class_ "todo-list" ] $ 131 | map (viewEntry False) (filter isVisible entries) ++ 132 | map (viewEntry True) (filter isVisible pendingEntries) 133 | ] 134 | where 135 | cssVisibility = bool "visible" "hidden" (null entries) 136 | allCompleted = all (L.view completed) entries 137 | isVisible entryRec = 138 | case visibility of 139 | ViewCompleted -> entryRec ^. completed 140 | ViewActive -> not $ entryRec ^. completed 141 | _ -> True 142 | 143 | viewEntry :: Bool -> EntryRecord -> View Action 144 | viewEntry isPending entryRec = liKeyed_ (toKey $ entryRec ^. eid) 145 | [ class_ $ S.intercalate " " $ 146 | [ "completed" | entryRec ^. completed ] <> [ "editing" | entryRec ^. entryRecEditing ] 147 | ] 148 | [ div_ 149 | (if isPending then [class_ "disabled", disabled_ "true"] else []) 150 | [ div_ 151 | [ class_ "view" ] 152 | [ input_ 153 | [ class_ "toggle" 154 | , type_ "checkbox" 155 | , checked_ $ entryRec ^. completed 156 | , onClick $ Check (entryRec ^. eid) (not $ entryRec ^. completed) 157 | ] [] 158 | , label_ 159 | [ onDoubleClick $ EditingEntry (entryRec ^. eid) True ] 160 | [ text $ toMisoString $ entryRec ^. description ] 161 | , button_ 162 | [ class_ "destroy" 163 | , onClick $ Delete (entryRec ^. eid) 164 | ] [] 165 | ] 166 | , input_ 167 | [ class_ "edit" 168 | , value_ (toMisoString $ entryRec ^. description) 169 | , name_ "title" 170 | , id_ $ "todo-" <> S.pack (show $ entryRec ^. eid) 171 | , onInput $ UpdateEntryDescription (entryRec ^. eid) . textFromJSString 172 | , onBlur $ UpdateEntry entry 173 | , onEnter $ UpdateEntry entry 174 | ] 175 | [] 176 | ] 177 | ] 178 | where 179 | entry = entryRec ^. entryRecEntry 180 | 181 | viewControls :: Model -> Visibility -> [ EntryRecord ] -> View Action 182 | viewControls model visibility entries = 183 | footer_ [ class_ "footer" 184 | , hidden_ (bool "" "hidden" $ null entries) 185 | ] 186 | [ viewControlsCount entriesLeft 187 | , viewControlsFilters visibility 188 | , viewControlsClear model entriesCompleted 189 | ] 190 | where 191 | entriesCompleted = length . filter (L.view completed) $ entries 192 | entriesLeft = length entries - entriesCompleted 193 | 194 | viewControlsCount :: Int -> View Action 195 | viewControlsCount entriesLeft = 196 | span_ [ class_ "todo-count" ] 197 | [ strong_ [] [ text $ S.pack (show entriesLeft) ] 198 | , text (item_ <> " left") 199 | ] 200 | where 201 | item_ = S.pack $ bool " items" " item" (entriesLeft == 1) 202 | 203 | viewControlsFilters :: Visibility -> View Action 204 | viewControlsFilters visibility = 205 | ul_ 206 | [ class_ "filters" ] 207 | [ visibilitySwap "#/" ViewAll visibility 208 | , text " " 209 | , visibilitySwap "#/active" ViewActive visibility 210 | , text " " 211 | , visibilitySwap "#/completed" ViewCompleted visibility 212 | ] 213 | 214 | visibilitySwap :: MisoString -> Visibility -> Visibility -> View Action 215 | visibilitySwap uri visibility actualVisibility = 216 | li_ [ ] 217 | [ a_ [ href_ uri 218 | , class_ $ S.concat [ "selected" | visibility == actualVisibility ] 219 | , onClick (ChangeVisibility visibility) 220 | ] [ viewVisibility visibility ] 221 | ] 222 | 223 | viewVisibility :: Visibility -> View a 224 | viewVisibility = text . \case 225 | ViewAll -> "All" 226 | ViewActive -> "Active" 227 | ViewCompleted -> "Completed" 228 | 229 | viewControlsClear :: Model -> Int -> View Action 230 | viewControlsClear _ entriesCompleted = 231 | button_ 232 | [ class_ "clear-completed" 233 | , prop "hidden" (entriesCompleted == 0) 234 | , onClick DeleteComplete 235 | ] 236 | [ text $ "Clear completed (" <> S.pack (show entriesCompleted) <> ")" ] 237 | 238 | viewInput :: Model -> T.Text -> View Action 239 | viewInput _ task = 240 | header_ [ class_ "header" ] 241 | [ h1_ [] [ text "nixtodo" ] 242 | , input_ 243 | [ class_ "new-todo" 244 | , placeholder_ "What needs to be done?" 245 | , autofocus_ True 246 | , value_ $ toMisoString task 247 | , name_ "newTodo" 248 | , onInput $ UpdateField . textFromJSString 249 | , onEnter Add 250 | ] [] 251 | ] 252 | 253 | onEnter :: Action -> Attribute Action 254 | onEnter action = 255 | onKeyDown $ bool NoOp action . (== KeyCode 13) 256 | 257 | infoFooter :: View a 258 | infoFooter = 259 | footer_ [ class_ "info" ] 260 | [ p_ [] [ text "Double-click to edit a todo" ] 261 | , p_ [] 262 | [ text "Written by " 263 | , a_ [ href_ "https://github.com/dmjio" ] [ text "David Johnson" ] 264 | ] 265 | , p_ [] 266 | [ text "Adapted by " 267 | , a_ [ href_ "https://github.com/basvandijk" ] [ text "Bas van Dijk" ] 268 | ] 269 | , p_ [] 270 | [ text "for the " 271 | , a_ [ href_ "https://github.com/basvandijk/nix-workshop" ] 272 | [ text "Nix Workshop @ Haskell eXchange 2017" ] 273 | ] 274 | , p_ [] 275 | [ text "Part of " 276 | , a_ [ href_ "http://todomvc.com" ] [ text "TodoMVC" ] 277 | ] 278 | , p_ [] 279 | [ text "Powered by " 280 | , icon "http://nixos.org" "static/nix.png" 281 | , icon "http://haskell.org" "static/haskell.png" 282 | ] 283 | ] 284 | where 285 | icon url imgUrl = 286 | a_ [ href_ url ] 287 | [ img_ [ src_ imgUrl 288 | , width_ "32" 289 | , style_ (M.fromList [("vertical-align", "middle")]) 290 | ] 291 | [] 292 | ] 293 | 294 | 295 | -------------------------------------------------------------------------------- 296 | -- Controller 297 | -------------------------------------------------------------------------------- 298 | 299 | data Action 300 | = NoOp 301 | | Initialize 302 | | SetEntries ![Entry] 303 | | WebSocketEvent !(Miso.WebSocket EntryEvent) -- TODO 304 | | UpdateField !T.Text 305 | | EditingEntry !EntryId !Bool 306 | | UpdateEntryDescription !EntryId !T.Text 307 | | UpdateEntry !Entry 308 | | Add 309 | | AddEntryResult !EntryId !(Either ServantError Entry) 310 | | Delete !EntryId 311 | | DeleteComplete 312 | | Check !EntryId !Bool 313 | | CheckAll !Bool 314 | | ChangeVisibility !Visibility 315 | 316 | updateModel :: Action -> Transition Action Model () 317 | updateModel = \case 318 | NoOp -> pure () 319 | 320 | Initialize -> scheduleIO $ do 321 | result <- callServant "" $ readEntries nixtodoApiClient 322 | case result of 323 | Left err -> do 324 | print err 325 | threadDelay 1e6 326 | pure Initialize 327 | Right entries -> pure $ SetEntries entries 328 | 329 | SetEntries entries -> do 330 | entryRecords .= 331 | [ EntryRecord 332 | { _entryRecEntry = entry 333 | , _entryRecEditing = False 334 | , _entryRecFocussed = False 335 | } 336 | | entry <- entries 337 | ] 338 | 339 | WebSocketEvent webSocketAction -> 340 | case webSocketAction of 341 | WebSocketMessage entryEvent -> 342 | case entryEvent of 343 | UpsertEntryEvent entry -> do 344 | let id' = entry ^. entryId 345 | entryRecords %= filterMap ((== id') . L.view eid) 346 | (entryRecEntry .~ entry) 347 | 348 | DeleteEntryEvent id' -> do 349 | entryRecords %= filter ((/= id') . L.view eid) 350 | 351 | WebSocketClose _closeCode _wasClean _reason -> pure () 352 | WebSocketOpen -> pure () 353 | WebSocketError err -> pure () 354 | 355 | Add -> do 356 | oldUid <- use uid 357 | oldField <- use field 358 | 359 | uid .= oldUid + 1 360 | field .= mempty 361 | 362 | unless (T.null oldField) $ do 363 | pendingEntryRecords %= (<> [newEntry oldField oldUid]) 364 | 365 | scheduleIO $ do 366 | addEntryResult <- callServant "" $ createEntry nixtodoApiClient 367 | EntryInfo 368 | { _entryInfDescription = oldField 369 | , _entryInfCompleted = False 370 | } 371 | pure $ AddEntryResult oldUid addEntryResult 372 | 373 | AddEntryResult id' addEntryResult -> do 374 | pendingEntryRecords %= filter ((/= id') . L.view eid) 375 | case addEntryResult of 376 | Left _err -> pure () 377 | Right entry -> do 378 | entryRecords %= 379 | (<> [ EntryRecord 380 | { _entryRecEntry = entry 381 | , _entryRecEditing = False 382 | , _entryRecFocussed = False 383 | } 384 | ] 385 | ) 386 | 387 | UpdateField str -> field .= str 388 | 389 | EditingEntry id' isEditing -> do 390 | editingEntry id' isEditing 391 | 392 | UpdateEntryDescription id' desc -> do 393 | entryRecords %= filterMap ((== id') . L.view eid) 394 | (description .~ desc) 395 | 396 | UpdateEntry entry -> do 397 | let id' = entry ^. entryId 398 | editingEntry id' False 399 | scheduleIO $ do 400 | _result <- callServant "" $ 401 | updateEntry nixtodoApiClient id' $ entry ^. entryEntry 402 | pure NoOp -- TODO: handle errors 403 | 404 | Delete id' -> do 405 | entryRecords %= filter ((/= id') . L.view eid) 406 | 407 | scheduleIO $ do 408 | _result <- callServant "" $ deleteEntry nixtodoApiClient id' 409 | pure NoOp -- TODO: handle error 410 | 411 | DeleteComplete -> do 412 | entryRecs <- use entryRecords 413 | let (completedRecs, uncompletedRecs) = partition (L.view completed) entryRecs 414 | entryRecords .= uncompletedRecs 415 | 416 | scheduleIO $ do 417 | F.for_ completedRecs $ \completedRec -> do 418 | let id' = completedRec ^. entryRecEntry . entryId 419 | _result <- callServant "" $ deleteEntry nixtodoApiClient id' 420 | pure () -- TODO: handle errors 421 | pure NoOp 422 | 423 | Check id' isCompleted -> do 424 | entryRecs <- use entryRecords 425 | let (selectedRecs, otherRecs) = partition ((id' ==) . L.view eid) entryRecs 426 | 427 | F.for_ (listToMaybe selectedRecs) $ \selectedRec -> do 428 | let checkedRec = selectedRec & completed .~ isCompleted 429 | entryRecords .= checkedRec : otherRecs 430 | 431 | scheduleIO $ do 432 | _result <- callServant "" $ updateEntry nixtodoApiClient id' 433 | (checkedRec ^. entryRecEntry . entryEntry) 434 | pure NoOp -- TODO: handle error 435 | 436 | CheckAll isCompleted -> -- TODO 437 | entryRecords %= filterMap (const True) 438 | (completed .~ isCompleted) 439 | 440 | ChangeVisibility v -> 441 | visibility .= v 442 | 443 | editingEntry :: EntryId -> Bool -> Transition Action Model () 444 | editingEntry id' isEditing = do 445 | entryRecords %= filterMap ((== id') . L.view eid) 446 | ( (entryRecEditing .~ isEditing) 447 | . (entryRecFocussed .~ isEditing) 448 | ) 449 | scheduleIO $ NoOp <$ focus ("todo-" <> S.pack (show id')) 450 | 451 | 452 | newEntry :: T.Text -> Int -> EntryRecord 453 | newEntry desc id' = EntryRecord 454 | { _entryRecEntry = Entry 455 | { _entryId = id' 456 | , _entryEntry = EntryInfo 457 | { _entryInfDescription = desc 458 | , _entryInfCompleted = False 459 | } 460 | } 461 | , _entryRecEditing = False 462 | , _entryRecFocussed = False 463 | } 464 | 465 | filterMap :: (a -> Bool) -> (a -> a) -> [a] -> [a] 466 | filterMap predicate f = go' 467 | where 468 | go' [] = [] 469 | go' (y:ys) 470 | | predicate y = f y : go' ys 471 | | otherwise = y : go' ys 472 | 473 | 474 | -------------------------------------------------------------------------------- 475 | -- Main 476 | -------------------------------------------------------------------------------- 477 | 478 | main :: IO () 479 | main = do 480 | websocketUrl <- getWebsocketUrl 481 | startApp App 482 | { initialAction = Initialize 483 | , model = emptyModel 484 | , view = viewModel 485 | , update = fromTransition . updateModel 486 | , events = defaultEvents 487 | , subs = [websocketSub (URL websocketUrl) (Protocols []) WebSocketEvent] 488 | } 489 | where 490 | getWebsocketUrl :: IO MisoString 491 | getWebsocketUrl = do 492 | window <- currentWindowUnchecked 493 | location <- getLocation window 494 | protocol <- getProtocol location 495 | host <- getHostname location 496 | port <- getPort location 497 | let wsProtocol = case protocol of 498 | "https:" -> "wss:" 499 | "http:" -> "ws:" 500 | _ -> error $ "I don't know how to convert protocol: " <> 501 | JSS.unpack protocol <> 502 | " to a websocket protocol!" 503 | pure $ wsProtocol <> "//" <> host <> ":" <> port <> "/websocket" 504 | 505 | emptyModel :: Model 506 | emptyModel = Model 507 | { _entryRecords = [] 508 | , _pendingEntryRecords = [] 509 | , _visibility = ViewAll 510 | , _field = mempty 511 | , _uid = 0 512 | } 513 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/static/default.css: -------------------------------------------------------------------------------- 1 | .disabled { 2 | opacity: 0.8; 3 | } 4 | -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/hs-pkgs/nixtodo-frontend/static/favicon.ico -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/static/haskell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/hs-pkgs/nixtodo-frontend/static/haskell.png -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/static/nix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/hs-pkgs/nixtodo-frontend/static/nix.png -------------------------------------------------------------------------------- /hs-pkgs/nixtodo-frontend/static/style.css: -------------------------------------------------------------------------------- 1 | body,html{margin:0;padding:0}.todomvc-wrapper{visibility:visible!important}button{margin:0;padding:0;border:0;background:0 0;font-size:100%;vertical-align:baseline;font-family:inherit;font-weight:inherit;color:inherit;-webkit-appearance:none;appearance:none;-webkit-font-smoothing:antialiased;-moz-font-smoothing:antialiased;font-smoothing:antialiased}body{font:14px 'Helvetica Neue',Helvetica,Arial,sans-serif;line-height:1.4em;background:#f5f5f5;color:#4d4d4d;min-width:230px;max-width:550px;margin:0 auto;-webkit-font-smoothing:antialiased;-moz-font-smoothing:antialiased;font-smoothing:antialiased;font-weight:300}button,input[type=checkbox]{outline:0}.hidden{display:none}.todoapp{background:#fff;margin:130px 0 40px 0;position:relative;box-shadow:0 2px 4px 0 rgba(0,0,0,.2),0 25px 50px 0 rgba(0,0,0,.1)}.todoapp input::-webkit-input-placeholder{font-style:italic;font-weight:300;color:#e6e6e6}.todoapp input::-moz-placeholder{font-style:italic;font-weight:300;color:#e6e6e6}.todoapp input::input-placeholder{font-style:italic;font-weight:300;color:#e6e6e6}.todoapp h1{position:absolute;top:-155px;width:100%;font-size:100px;font-weight:100;text-align:center;color:rgba(175,47,47,.15);-webkit-text-rendering:optimizeLegibility;-moz-text-rendering:optimizeLegibility;text-rendering:optimizeLegibility}.edit,.new-todo{position:relative;margin:0;width:100%;font-size:24px;font-family:inherit;font-weight:inherit;line-height:1.4em;border:0;outline:0;color:inherit;padding:6px;border:1px solid #999;box-shadow:inset 0 -1px 5px 0 rgba(0,0,0,.2);box-sizing:border-box;-webkit-font-smoothing:antialiased;-moz-font-smoothing:antialiased;font-smoothing:antialiased}.new-todo{padding:16px 16px 16px 60px;border:none;background:rgba(0,0,0,.003);box-shadow:inset 0 -2px 1px rgba(0,0,0,.03)}.main{position:relative;z-index:2;border-top:1px solid #e6e6e6}label[for=toggle-all]{display:none}.toggle-all{position:absolute;top:-55px;left:-12px;width:60px;height:34px;text-align:center;border:none}.toggle-all:before{content:'❯';font-size:22px;color:#e6e6e6;padding:10px 27px 10px 27px}.toggle-all:checked:before{color:#737373}.todo-list{margin:0;padding:0;list-style:none}.todo-list li{position:relative;font-size:24px;border-bottom:1px solid #ededed}.todo-list li:last-child{border-bottom:none}.todo-list li.editing{border-bottom:none;padding:0}.todo-list li.editing .edit{display:block;width:506px;padding:13px 17px 12px 17px;margin:0 0 0 43px}.todo-list li.editing .view{display:none}.todo-list li .toggle{text-align:center;width:40px;height:auto;position:absolute;top:0;bottom:0;margin:auto 0;border:none;-webkit-appearance:none;appearance:none}.todo-list li .toggle:after{content:url('data:image/svg+xml;utf8,')}.todo-list li .toggle:checked:after{content:url('data:image/svg+xml;utf8,')}.todo-list li label{white-space:pre-line;word-break:break-all;padding:15px 60px 15px 15px;margin-left:45px;display:block;line-height:1.2;transition:color .4s}.todo-list li.completed label{color:#d9d9d9;text-decoration:line-through}.todo-list li .destroy{display:none;position:absolute;top:0;right:10px;bottom:0;width:40px;height:40px;margin:auto 0;font-size:30px;color:#cc9a9a;margin-bottom:11px;transition:color .2s ease-out}.todo-list li .destroy:hover{color:#af5b5e}.todo-list li .destroy:after{content:'×'}.todo-list li:hover .destroy{display:block}.todo-list li .edit{display:none}.todo-list li.editing:last-child{margin-bottom:-1px}.footer{color:#777;padding:10px 15px;height:20px;text-align:center;border-top:1px solid #e6e6e6}.footer:before{content:'';position:absolute;right:0;bottom:0;left:0;height:50px;overflow:hidden;box-shadow:0 1px 1px rgba(0,0,0,.2),0 8px 0 -3px #f6f6f6,0 9px 1px -3px rgba(0,0,0,.2),0 16px 0 -6px #f6f6f6,0 17px 2px -6px rgba(0,0,0,.2)}.todo-count{float:left;text-align:left}.todo-count strong{font-weight:300}.filters{margin:0;padding:0;list-style:none;position:absolute;right:0;left:0}.filters li{display:inline}.filters li a{color:inherit;margin:3px;padding:3px 7px;text-decoration:none;border:1px solid transparent;border-radius:3px}.filters li a.selected,.filters li a:hover{border-color:rgba(175,47,47,.1)}.filters li a.selected{border-color:rgba(175,47,47,.2)}.clear-completed,html .clear-completed:active{float:right;position:relative;line-height:20px;text-decoration:none;cursor:pointer;position:relative}.clear-completed:hover{text-decoration:underline}.info{margin:65px auto 0;color:#bfbfbf;font-size:10px;text-shadow:0 1px 0 rgba(255,255,255,.5);text-align:center}.info p{line-height:1}.info a{color:inherit;text-decoration:none;font-weight:400}.info a:hover{text-decoration:underline}@media screen and (-webkit-min-device-pixel-ratio:0){.todo-list li .toggle,.toggle-all{background:0 0}.todo-list li .toggle{height:40px}.toggle-all{-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-appearance:none;appearance:none}}@media (max-width:430px){.footer{height:50px}.filters{bottom:10px}} -------------------------------------------------------------------------------- /jobset.nix: -------------------------------------------------------------------------------- 1 | # A declarative hydra jobset specification. 2 | # See https://github.com/NixOS/hydra/blob/master/doc/manual/declarative-projects.xml 3 | 4 | { nixpkgs, declInput }: let pkgs = import nixpkgs {}; in { 5 | jobsets = pkgs.runCommand "spec.json" {} '' 6 | cat < $out <} || exit 1 25 | 26 | ./create_or_update_roles.sh ${cfg.passwordFile} 27 | ''; 28 | in { 29 | options.services.nixtodo.db = { 30 | enable = mkEnableOption "nixtodo DB"; 31 | 32 | passwordFile = mkOption { 33 | type = types.str; 34 | description = '' 35 | A file that contains the password of the nixtodo PostgreSQL role. 36 | ''; 37 | }; 38 | }; 39 | 40 | config = mkIf config.services.nixtodo.db.enable { 41 | services.postgresql = { 42 | enable = true; 43 | 44 | # PostgreSQL 10.0 got released last week. Lets use it! 45 | package = pkgs.postgresql100; 46 | 47 | dataDir = "/var/lib/postgresql/${pgPkg.psqlSchema}"; 48 | extraConfig = '' 49 | # Handy for debugging 50 | log_statement all 51 | ''; 52 | }; 53 | systemd.services."create-roles-nixtodo-db" = { 54 | description = "Create or update roles for the nixtodo_db"; 55 | wants = [ "postgresql.service" ]; 56 | after = [ "postgresql.service" ]; 57 | wantedBy = [ "multi-user.target" ]; 58 | serviceConfig = { 59 | ExecStart = createDbRoles; 60 | Type = "oneshot"; 61 | RemainAfterExit = "yes"; 62 | User = "postgres"; 63 | }; 64 | }; 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /modules/backend/default.nix: -------------------------------------------------------------------------------- 1 | # This module configures a complete nixtodo backend machine. 2 | # 3 | # * It enables the nixtodo database. 4 | # 5 | # * It enables the nixtodo backend service. 6 | # 7 | # * It enables a nginx reverse proxy server for handling TLS 8 | # * termination. 9 | 10 | { config, lib, pkgs, ... }: 11 | 12 | with lib; 13 | 14 | let backendPort = config.services.nixtodo.server.web-server.port; 15 | in { 16 | options.services.nixtodo.enable = mkEnableOption "nixtodo"; 17 | 18 | config = mkIf config.services.nixtodo.enable { 19 | networking.firewall.allowedTCPPorts = [ 80 443 ]; 20 | services = { 21 | nginx = { 22 | enable = true; 23 | recommendedTlsSettings = true; 24 | recommendedOptimisation = true; 25 | recommendedProxySettings = true; 26 | sslDhparam = ; 27 | upstreams = { 28 | "nixtodo-backend" = { 29 | servers = { "127.0.0.1:${toString backendPort}" = {}; }; 30 | }; 31 | }; 32 | virtualHosts = { 33 | "nixtodo.com" = { 34 | default = true; 35 | forceSSL = true; 36 | enableACME = true; 37 | locations = { 38 | "/" = { 39 | proxyPass = "http://nixtodo-backend"; 40 | proxyWebsockets = true; 41 | }; 42 | }; 43 | }; 44 | }; 45 | }; 46 | 47 | nixtodo.server = { 48 | enable = true; 49 | db.passwordFile = config.services.nixtodo.db.passwordFile; 50 | }; 51 | 52 | nixtodo.db = { 53 | enable = true; 54 | 55 | # TODO: don't store the password in the world readable Nix store! 56 | passwordFile = toString (pkgs.writeTextFile { 57 | name = "postgresql-nixtodo-role-password"; 58 | text = fileContents ; 59 | }); 60 | }; 61 | }; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /modules/backend/server.nix: -------------------------------------------------------------------------------- 1 | # This module configures the nixtodo backend server. 2 | # 3 | # * It defines the `nixtodo-backend.conf` configuration file. 4 | # 5 | # * It installs a `nixtodo-backend` user account and group which will 6 | # * be running the backend. 7 | # 8 | # * It installs a systemd service for running the backend. 9 | # 10 | # * It configures the systemd service to first run the 11 | # `migrate-nixtodo-db` script, which migrates the database to the 12 | # desired state, before running the backend. 13 | 14 | { config, pkgs, lib, ... }: 15 | 16 | with lib; 17 | 18 | let 19 | cfg = config.services.nixtodo.server; 20 | 21 | nixtodoBackendConf = pkgs.writeTextFile { 22 | name = "nixtodo-backend.conf"; 23 | text = '' 24 | db 25 | { 26 | host = "127.0.0.1" 27 | port = "${toString config.services.postgresql.port}" 28 | database = "nixtodo_db" 29 | user = "nixtodo" 30 | passwordFile = "${cfg.db.passwordFile}" 31 | 32 | pool 33 | { 34 | numStripes = 2 35 | maxResources = 25 36 | idleTime = 60 37 | } 38 | } 39 | 40 | frontendIndexTemplater 41 | { 42 | indexTemplatePath = "${pkgs.nixtodo.frontend-static}/index.html.mustache" 43 | srcDir = "${pkgs.nixtodo.frontend-static}/static" 44 | dstDir = "hashed-frontend" 45 | urlPrefix = "hashed" 46 | compressLevel = 9 47 | } 48 | 49 | web-server 50 | { 51 | host = "127.0.0.1" 52 | port = "${toString cfg.web-server.port}" 53 | } 54 | ''; 55 | }; 56 | 57 | user = "nixtodo-backend"; 58 | group = user; 59 | 60 | stateDir = "/var/lib/nixtodo-backend"; 61 | 62 | migrateDb = 63 | let PATH = makeSearchPath "bin" [ pkgs.coreutils pkgs.gnugrep config.services.postgresql.package ]; 64 | db = ; 65 | migrate = '' 66 | port="${toString config.services.postgresql.port}" 67 | nixtodoPasswordFile="${cfg.db.passwordFile}" 68 | ${db + "/create_or_update_db.sh"} "nixtodo_db" "$port" "$nixtodoPasswordFile" "migrations" 69 | 70 | export PGPASSWORD 71 | PGPASSWORD="$(cat $nixtodoPasswordFile)" 72 | ''; 73 | in pkgs.writeScript "migrate-nixtodo-db" '' 74 | #!/bin/sh 75 | set -o errexit 76 | PATH="${PATH}" 77 | 78 | if [ "${stateDir}/latest-migration" -ef "${db}" ]; then 79 | echo "No need to migrate the nixtodo-db because it has already been migrated with ${db}" 80 | exit 81 | fi 82 | 83 | cd ${db} || exit 1 84 | 85 | ${migrate} 86 | ln -sfT "${db}" "${stateDir}/latest-migration" 87 | ''; 88 | 89 | in { 90 | options.services.nixtodo.server = { 91 | enable = mkEnableOption "nixtodo backend server"; 92 | 93 | db.passwordFile = mkOption { 94 | type = types.str; 95 | description = '' 96 | A file that contains the password of the nixtodo PostgreSQL role. 97 | ''; 98 | }; 99 | 100 | web-server.port = mkOption { 101 | type = types.int; 102 | default = 8000; 103 | description = '' 104 | Port to listen on (for http). 105 | ''; 106 | }; 107 | 108 | }; 109 | 110 | config = mkIf cfg.enable { 111 | systemd.services.nixtodo-backend = { 112 | description = "nixtodo-backend"; 113 | wants = [ "create-roles-nixtodo-db.service" ]; 114 | after = [ "create-roles-nixtodo-db.service" "network.target" ]; 115 | wantedBy = [ "multi-user.target" ]; 116 | serviceConfig = { 117 | User = user; 118 | Group = group; 119 | WorkingDirectory = stateDir; 120 | ExecStartPre = migrateDb; 121 | ExecStart = 122 | "${pkgs.haskellPackages.nixtodo-backend}/bin/nixtodo-backend" + 123 | " --config=${nixtodoBackendConf}"; 124 | Restart = "always"; 125 | }; 126 | }; 127 | 128 | users = { 129 | users."${user}" = { 130 | name = user; 131 | group = group; 132 | home = stateDir; 133 | createHome = true; 134 | }; 135 | groups."${group}" = { 136 | name = group; 137 | }; 138 | }; 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /modules/base.nix: -------------------------------------------------------------------------------- 1 | # This NixOS module provides a base layer for all nixtodo machines: 2 | # 3 | # * It imports to bring all nixtodo 4 | # services into scope. 5 | # 6 | # * It installs some handy packages like `htop`. 7 | # 8 | # * It enables a SSH daemon so we can log in to our machine. 9 | # 10 | # * It installs user accounts for all nixtodo engineers. 11 | 12 | { lib, pkgs, ... }: 13 | 14 | with lib; 15 | 16 | let # `engineers` contains a set of user configurations for all 17 | # nixtodo engineers. This set can be assigned to the NixOS 18 | # `users.users` option (like we do below) to install user accounts 19 | # on the machine. The set is based on the ./users directory which 20 | # contains a directory for each user. 21 | engineers = with builtins; 22 | genAttrs (attrNames (readDir ./users)) (name : 23 | let userDir = ./users + "/${name}"; in { 24 | inherit name; 25 | isNormalUser = true; 26 | extraGroups = [ "systemd-journal" "nixtodo" ]; 27 | openssh.authorizedKeys.keys = 28 | let sshPublicKeyPath = userDir + /.ssh/id_rsa.pub; 29 | in optional (pathExists sshPublicKeyPath) 30 | (readFile sshPublicKeyPath); 31 | } 32 | ); 33 | in { 34 | imports = [ ]; 35 | 36 | nixpkgs.overlays = [ (import ) ]; 37 | 38 | environment.systemPackages = with pkgs; [ htop ]; 39 | 40 | services.openssh.enable = true; 41 | 42 | networking.firewall.allowPing = true; 43 | 44 | security = { 45 | sudo.wheelNeedsPassword = false; 46 | initialRootPassword = "!"; # This disables the root password. 47 | }; 48 | 49 | users = { 50 | # This disables dynamic user account creation via the `useradd` 51 | # command. The less dynamism and the more static configuration the 52 | # better. 53 | mutableUsers = false; 54 | 55 | groups.nixtodo = { name = "nixtodo"; }; 56 | 57 | users = engineers // { 58 | "root" = { 59 | # In order for engineers to directly log in as root via SSH 60 | # we assign their authorized keys to the root user. 61 | # This is probably a security hazard... 62 | openssh.authorizedKeys.keys = 63 | concatMap (user : user.openssh.authorizedKeys.keys) 64 | (attrValues engineers); 65 | }; 66 | }; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /modules/cache.nixtodo.com-public-key: -------------------------------------------------------------------------------- 1 | cache.nixtodo.com-1:MvqEKuqrFuDTTjkyekH+MJZHHEU+NHUTuudmNVgwhgI= -------------------------------------------------------------------------------- /modules/default.nix: -------------------------------------------------------------------------------- 1 | # This module imports all other modules so you only need to import 2 | # this module to get access to all the nixtodo services. 3 | # 4 | # Note that the ./base.nix module, which serves as the base layer for 5 | # all nixtodo machines, imports this file. 6 | { 7 | imports = [ 8 | ./backend 9 | ./backend/db.nix 10 | ./backend/server.nix 11 | ./hydra.nix 12 | ]; 13 | } 14 | -------------------------------------------------------------------------------- /modules/github.com-rsa_key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== -------------------------------------------------------------------------------- /modules/hydra.nix: -------------------------------------------------------------------------------- 1 | # This module: 2 | # 3 | # * enables the hydra Continuous Integration server. This CI server periodically 4 | # pulls our git repo and builds all the jobs defined in . 5 | # 6 | # * enables the nix-serve daemon which serves a binary cache of the build 7 | # artefacts that hydra produces. This cache can be used by our engineers so 8 | # they don't have to build as much on their local workstations. 9 | # 10 | # * configures a nginx reverse proxy server that provides TLS termination and 11 | # proxies hydra.nixtodo.com and cache.nixtodo.com to hydra and nix-serve 12 | # respectively. 13 | 14 | { pkgs, config, lib, ... }: 15 | 16 | with lib; 17 | 18 | let 19 | cfg = config.services.nixtodo.hydra; 20 | 21 | in { 22 | options.services.nixtodo.hydra = { 23 | enable = mkEnableOption "the lumi Hydra service"; 24 | 25 | hostname = mkOption { 26 | type = types.str; 27 | default = "hydra.nixtodo.com"; 28 | description = '' 29 | The hostname of the hydra machine. 30 | ''; 31 | }; 32 | 33 | devopsPasswordFile = mkOption { 34 | type = types.str; 35 | default = toString (pkgs.writeTextFile { 36 | name = "devops-hydra-password"; 37 | text = fileContents ; 38 | }); 39 | 40 | description = '' 41 | A file that contains the password of the 'devops' admin account on hydra. 42 | ''; 43 | }; 44 | 45 | hydraGithubPrivateKey = mkOption { 46 | type = types.path; 47 | default = ; 48 | description = '' 49 | The SSH private key of the hydra-github keypair. The public key of this 50 | pair should be uploaded to GitHub to ensure hydra can clone repos. 51 | ''; 52 | }; 53 | 54 | secretKeyFile = mkOption { 55 | type = types.str; 56 | default = toString (pkgs.writeTextFile { 57 | name = "cache.nixtodo.com-secret-key"; 58 | text = fileContents ; 59 | }); 60 | description = '' 61 | Path to a file that contains the secret key for signing the binary cache. 62 | ''; 63 | }; 64 | }; 65 | 66 | config = mkIf cfg.enable { 67 | 68 | networking.firewall.allowedTCPPorts = [ 80 443 ]; 69 | 70 | services.nginx = { 71 | enable = true; 72 | recommendedTlsSettings = true; 73 | recommendedOptimisation = true; 74 | recommendedProxySettings = true; 75 | sslDhparam = ; 76 | upstreams = { 77 | "hydra" = { 78 | servers = { "127.0.0.1:${toString config.services.hydra.port}" = {}; }; 79 | }; 80 | "nix-serve" = { 81 | servers = { "127.0.0.1:${toString config.services.nix-serve.port}" = {}; }; 82 | }; 83 | 84 | }; 85 | virtualHosts = { 86 | "hydra.nixtodo.com" = { 87 | default = true; 88 | forceSSL = true; 89 | enableACME = true; 90 | locations = { 91 | "/" = { proxyPass = "http://hydra"; }; 92 | }; 93 | }; 94 | "cache.nixtodo.com" = { 95 | forceSSL = true; 96 | enableACME = true; 97 | locations = { 98 | "/" = { proxyPass = "http://nix-serve"; }; 99 | }; 100 | }; 101 | }; 102 | }; 103 | 104 | services.hydra = { 105 | enable = true; 106 | hydraURL = "https://${cfg.hostname}"; 107 | notificationSender = "hydra@nixtodo.com"; 108 | logo = ; 109 | }; 110 | 111 | services.nix-serve = { 112 | enable = true; 113 | bindAddress = "127.0.0.1"; 114 | inherit (cfg) secretKeyFile; 115 | }; 116 | 117 | nix = { 118 | buildMachines = [ 119 | { hostName = "localhost"; 120 | system = "x86_64-linux"; 121 | supportedFeatures = ["kvm" "nixos-test" "big-parallel" "benchmark"]; 122 | maxJobs = 8; 123 | } 124 | ]; 125 | }; 126 | 127 | # Register GitHub's host key as a known host so that hydra can connect to 128 | # GitHub to clone our repo's without being prompted. 129 | programs.ssh = { 130 | knownHosts = [ 131 | { hostNames = ["github.com"]; 132 | publicKeyFile = ./github.com-rsa_key.pub; 133 | } 134 | ]; 135 | }; 136 | 137 | # Create a hydra admin user named "devops" and copy the GitHub private SSH 138 | # key to hydra's home directory so that it can connect to GitHub to clone 139 | # our repo's (this is only needed for private repos). 140 | systemd.services.lumi-hydra-setup = { 141 | wantedBy = [ "multi-user.target" ]; 142 | requires = [ "hydra-init.service" "postgresql.service" ]; 143 | after = [ "hydra-init.service" "postgresql.service" ]; 144 | environment = config.systemd.services.hydra-init.environment; 145 | path = [ config.services.hydra.package ]; 146 | script = 147 | let hydraHome = config.users.users.hydra.home; 148 | hydraQueueRunnerHome = config.users.users.hydra-queue-runner.home; 149 | in '' 150 | hydra-create-user devops \ 151 | --full-name 'DevOps' \ 152 | --email-address 'devops@lumiguide.nl' \ 153 | --password "$(cat ${cfg.devopsPasswordFile})" \ 154 | --role admin 155 | 156 | # TODO: Don't store the private SSH keys in the public Nix store! 157 | mkdir -p "${hydraHome}/.ssh" 158 | chmod 700 "${hydraHome}/.ssh" 159 | cp "${cfg.hydraGithubPrivateKey}" "${hydraHome}/.ssh/id_rsa" 160 | chown -R hydra:hydra "${hydraHome}/.ssh" 161 | chmod 600 "${hydraHome}/.ssh/id_rsa" 162 | ''; 163 | serviceConfig = { 164 | Type = "oneshot"; 165 | RemainAfterExit = true; 166 | }; 167 | }; 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /modules/nixtodo-net/README.md: -------------------------------------------------------------------------------- 1 | This directory contains the configuration of the nixtodo nixops 2 | network of machines (it currently consists of a single machine). 3 | 4 | The `./default.nix` file contains the logical configuration. 5 | 6 | nixops supports multiple deployment options. You can deploy to 7 | Microsoft Azure, Digital Ocean, AWS EC2, Google Compute Cloud, 8 | Hetzner, Virtualbox and any machine running NixOS (so that you can 9 | deploy to your own physical hardware for example). 10 | 11 | The nixtodo network support both deploying to containers for testing 12 | and deploying to AWS EC2 for production. See the `./containerized.nix` 13 | and `aws.nix` files respectively. 14 | -------------------------------------------------------------------------------- /modules/nixtodo-net/aws.nix: -------------------------------------------------------------------------------- 1 | # This nixops network specification defines the production 2 | # configuration of the nixtodo network. 3 | # 4 | # As you can see we deploy machines to EC2 instances on Amazon Web 5 | # Services. Note that we also provision some resources like a SSH 6 | # key-pair and an elastic IP that are both used by the instances. 7 | 8 | let 9 | region = "eu-west-1"; # A data center in Ireland 10 | 11 | accessKeyId = "todo-list-app"; 12 | 13 | in rec { 14 | backend = {resources, ... }: { 15 | deployment = { 16 | targetEnv = "ec2"; 17 | ec2 = { 18 | inherit region accessKeyId; 19 | instanceType = "t2.micro"; 20 | 21 | # I'm expecting nixtodo.com to be a huge success so I better 22 | # provision some gigabytes to store the world's TODO entries. 23 | ebsInitialRootDiskSize = 10; # GB 24 | 25 | keyPair = resources.ec2KeyPairs.todo-list-app-key-pair; 26 | elasticIPv4 = resources.elasticIPs.nixtodo-elastic-ip; 27 | }; 28 | }; 29 | }; 30 | 31 | support = {resources, ... }: { 32 | deployment = { 33 | targetEnv = "ec2"; 34 | ec2 = { 35 | inherit region accessKeyId; 36 | instanceType = "t2.micro"; 37 | 38 | ebsInitialRootDiskSize = 20; # GB 39 | 40 | keyPair = resources.ec2KeyPairs.todo-list-app-key-pair; 41 | elasticIPv4 = resources.elasticIPs.nixtodo-support-elastic-ip; 42 | }; 43 | }; 44 | }; 45 | 46 | resources = { 47 | ec2KeyPairs.todo-list-app-key-pair = 48 | { inherit region accessKeyId; }; 49 | 50 | elasticIPs.nixtodo-elastic-ip = 51 | { inherit region accessKeyId; }; 52 | elasticIPs.nixtodo-support-elastic-ip = 53 | { inherit region accessKeyId; }; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /modules/nixtodo-net/containerized.nix: -------------------------------------------------------------------------------- 1 | # For testing the nixtodo network each machine can be deployed to a 2 | # container running on a NixOS host. This host is typically your own 3 | # workstation. 4 | # 5 | # After deploying the containers the `nixos-container` command can be 6 | # used to interact with the machines. 7 | 8 | let 9 | # Setting up a working TLS implementation (HTTPS) in a testing 10 | # environment can be difficult because you have to install 11 | # self-signed certificates and configure your browsers and other 12 | # clients to use it. 13 | # 14 | # Because of this we disable TLS in our containerized 15 | # network. Note how we use the `mkForce` function from `lib` to 16 | # override the options which were already defined elsewhere. 17 | disableTLS = hostname : { lib, ... } : { 18 | services.nginx = with lib; { 19 | recommendedTlsSettings = mkForce false; 20 | virtualHosts."${hostname}" = { 21 | forceSSL = mkForce false; 22 | enableACME = mkForce false; 23 | }; 24 | }; 25 | }; 26 | in { 27 | backend = { lib, ... }: { 28 | deployment.targetEnv = "container"; 29 | imports = [ (disableTLS "nixtodo.com") ]; 30 | }; 31 | 32 | support = { 33 | deployment.targetEnv = "container"; 34 | imports = [ 35 | (disableTLS "hydra.nixtodo.com") 36 | (disableTLS "cache.nixtodo.com") 37 | ]; 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /modules/nixtodo-net/default.nix: -------------------------------------------------------------------------------- 1 | # This file defines the logical configuration of the nixtodo nixops network. It 2 | # can be deployed to containers by combining it with the `./containerized.nix` 3 | # file or to AWS EC2 by combining it with the `aws.nix` file. 4 | # 5 | # See the `` how to turn this into a nixops network. 6 | { 7 | network.description = "nixtodo network"; 8 | 9 | # This configures a base layer of functionality for each machine. 10 | defaults = ; 11 | 12 | # The network currently consists of a single machine callled `backend` running 13 | # the nixtodo service. 14 | backend = { 15 | services.nixtodo.enable = true; 16 | }; 17 | 18 | support = { 19 | services.nixtodo.hydra.enable = true; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /modules/users/bas/.ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDcOqM5rTK98IgjsA+qSQTwiqH/X4Ol9j0d9PY9D7z+BafPmNHuuZ3cQ+myoYYUOr3GypIYQiDVUH1O4enG5p/l7uQs9ounZequO3R9e6hV3bdRfMtypYd+gXwBPmkbUZuwT8TW8U2xC8Q+LBGY0/ojBh1eRJdXRy+lyxRabbjfyhaPWxVG/3l9pnF/QcIqNFENz2TXXFZdKMrTj1Pl50+p0GO9c8pUzAYUvyMmft43K0PXcNd+cLAiOvK8L042VyBSGKmFXluukPdo8Aonl+d9tOJ+8dsqsV/dHd0A9O+pQKfHPR203Wx9AwD8rQEsYBGWpInS2BWPeudWDAC8O3Lmv0FLQVmPvQ/0kr9suhUfuMh1yUojwqiTVTcKwJUazYEb5DxGZmV0tHK40tNxV0QzHjd+kcCgDYfqmZmA37TyM3K54GZp3XDJtH/Bnu/Y0GR4k91iPWWR3ZqkO2/OWtn9yC8TQ7SuzyJxzKfazDjlXo4VGQFmnZ6ZLO6+g0a5z09qOLibPnHxYwcyJBmT3E+Pbz1mwrMlBs1bZUOSFczhcBgFpewbQLPoP4WH6fx4xOkGzlO8JMTOHlKPBKluyU1y9di9okwZbyFGYkykm6hgzPxvcxbu5MpXeD/OB7fXKLhu2fNnEAzLj/OEvu+1zzRdIVOVW/9h1aTmhKFFLnK23Q== bas@lumi.guide 2 | -------------------------------------------------------------------------------- /modules/users/peti/.ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDK5wqjMqm0N8gpiGB9RCbTyApFwNEzSK2RpYrcev7/5ucfTuSOAKxCM799l/l29WB7Nu+SQWaSyJxI5sIs/QSMT4rCFm2X0hDk0xpPup9AhD+JsCz9uS+dU1Fi9n16lGNP1ulmi9aMw+hJC/ASzGJvvl32H4oHE4ABd65ou5TaSnJDX/K7osPnedMlezHsv7/+mEQoZv/a+OYt4eUmwiWEWKFPv4oYylBity1U67GJY0pvMNLVoqsB3TlcxvuLZZJJm9vuSARJN/7ldiE/JYtiZoZL14tuNUlHVREN8ERt8Ey9YDV0uW0X0fn18DYP3S6K4ckhG+KA+hllvBc5k0jF simons@cryp.to 2 | -------------------------------------------------------------------------------- /nix/README.md: -------------------------------------------------------------------------------- 1 | This directory contains all the Nix code to extend nixpkgs with our own packages. 2 | 3 | First study `./overlay.nix`. It's imported from 4 | and imports all other files in this directory. 5 | -------------------------------------------------------------------------------- /nix/haskell-overrides.nix: -------------------------------------------------------------------------------- 1 | # This Nix expression describes how we override and extend the Haskell packages 2 | # from . 3 | 4 | pkgs : { 5 | 6 | overrides = self: super: 7 | let 8 | thirdPartyHsPkgs = let dir = ./haskell; in 9 | pkgs.lib.genAttrs 10 | (builtins.attrNames (builtins.readDir dir)) 11 | (name : self.callPackage (dir + "/${name}") {}); 12 | 13 | hsPkgs = let dir = ; in 14 | pkgs.lib.genAttrs 15 | (builtins.filter 16 | (name: builtins.pathExists (dir + "/${name}/${name}.cabal")) 17 | (builtins.attrNames (builtins.readDir dir))) 18 | (name : let pkgDir = dir + "/${name}"; 19 | drv'' = self.callPackage ( 20 | super.haskellSrc2nix { 21 | inherit name; 22 | src = pkgDir + "/${name}.cabal"; 23 | } 24 | ) {}; 25 | drv' = pkgs.haskell.lib.overrideCabal drv'' (_drv : { 26 | src = pkgs.lib.sourceByRegex pkgDir [ 27 | "^${name}.cabal$" 28 | "^LICENSE$" 29 | ".*\.hs" 30 | "^src$" "^src/.*" 31 | "^test$" "^test/.*" 32 | "^include$" "^include/.*" 33 | ]; 34 | }); 35 | overridePath = pkgDir + "/override.nix"; 36 | drv = if builtins.pathExists overridePath 37 | then import overridePath pkgs drv' 38 | else drv'; 39 | in drv 40 | ); 41 | 42 | in thirdPartyHsPkgs // hsPkgs // { 43 | 44 | miso = let version = "0.8.0.0"; in super.callPackage (super.haskellSrc2nix { 45 | name = "miso-${version}"; 46 | src = pkgs.fetchurl { 47 | url = "http://hackage.haskell.org/package/miso-${version}/miso.cabal"; 48 | sha256 = "1xbz76pvpapbl3xsb5g1355pslg1b56xnm1x0xvapyyf2y2x8w47"; 49 | }; 50 | sha256 = "1z49dd3g30fhk6kvm5lfrzapsbf3381bmgyzsp34f67fdjxmdj8w"; 51 | }) {}; 52 | 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /nix/haskell/servant-client-core/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, base, base-compat, base64-bytestring, bytestring 2 | , containers, deepseq, exceptions, generics-sop, hspec 3 | , http-api-data, http-media, http-types, mtl, network-uri 4 | , QuickCheck, safe, servant, stdenv, text, fetchFromGitHub 5 | }: 6 | mkDerivation { 7 | pname = "servant-client-core"; 8 | version = "0.11"; 9 | src = (fetchFromGitHub { 10 | owner = "LumiGuide"; 11 | repo = "servant"; 12 | sha256 = "08b5z96v1b3izs2rd2fra35w03kb69wb9r68ia2r489kbynz19ch"; 13 | rev = "ce355147d0bf3d1eefc1dc049f1cfa3f008d38c9"; 14 | }) + "/servant-client-core"; 15 | libraryHaskellDepends = [ 16 | base base-compat base64-bytestring bytestring containers exceptions 17 | generics-sop http-api-data http-media http-types mtl network-uri 18 | safe servant text 19 | ]; 20 | testHaskellDepends = [ base base-compat deepseq hspec QuickCheck ]; 21 | homepage = "http://haskell-servant.readthedocs.org/"; 22 | description = "Core functionality and class for client function generation for servant APIs"; 23 | license = stdenv.lib.licenses.bsd3; 24 | } 25 | -------------------------------------------------------------------------------- /nix/haskell/servant-client-ghcjs/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, base, bytestring, case-insensitive, containers 2 | , exceptions, ghcjs-base, ghcjs-prim, http-types, monad-control 3 | , mtl, semigroupoids, servant-client-core, stdenv 4 | , string-conversions, transformers, transformers-base, fetchFromGitHub 5 | }: 6 | mkDerivation { 7 | pname = "servant-client-ghcjs"; 8 | version = "0.11"; 9 | src = (fetchFromGitHub { 10 | owner = "LumiGuide"; 11 | repo = "servant"; 12 | sha256 = "08b5z96v1b3izs2rd2fra35w03kb69wb9r68ia2r489kbynz19ch"; 13 | rev = "ce355147d0bf3d1eefc1dc049f1cfa3f008d38c9"; 14 | }) + "/servant-client-ghcjs"; 15 | libraryHaskellDepends = [ 16 | base bytestring case-insensitive containers exceptions ghcjs-base 17 | ghcjs-prim http-types monad-control mtl semigroupoids 18 | servant-client-core string-conversions transformers 19 | transformers-base 20 | ]; 21 | homepage = "http://haskell-servant.readthedocs.org/"; 22 | description = "automatical derivation of querying functions for servant webservices for ghcjs"; 23 | license = stdenv.lib.licenses.bsd3; 24 | } 25 | -------------------------------------------------------------------------------- /nix/haskell/servant-client/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, aeson, attoparsec, base, base-compat, bytestring 2 | , containers, deepseq, exceptions, generics-sop, hspec 3 | , http-api-data, http-client, http-client-tls, http-media 4 | , http-types, HUnit, monad-control, mtl, network, QuickCheck 5 | , semigroupoids, servant, servant-client-core, servant-server 6 | , stdenv, text, transformers, transformers-base 7 | , transformers-compat, wai, warp, fetchFromGitHub 8 | }: 9 | mkDerivation { 10 | pname = "servant-client"; 11 | version = "0.11"; 12 | src = (fetchFromGitHub { 13 | owner = "LumiGuide"; 14 | repo = "servant"; 15 | sha256 = "08b5z96v1b3izs2rd2fra35w03kb69wb9r68ia2r489kbynz19ch"; 16 | rev = "ce355147d0bf3d1eefc1dc049f1cfa3f008d38c9"; 17 | }) + "/servant-client"; 18 | libraryHaskellDepends = [ 19 | aeson attoparsec base base-compat bytestring containers exceptions 20 | http-client http-client-tls http-media http-types monad-control mtl 21 | semigroupoids servant-client-core text transformers 22 | transformers-base transformers-compat 23 | ]; 24 | testHaskellDepends = [ 25 | aeson base base-compat bytestring containers deepseq generics-sop 26 | hspec http-api-data http-client http-media http-types HUnit mtl 27 | network QuickCheck servant servant-client-core servant-server text 28 | transformers transformers-compat wai warp 29 | ]; 30 | homepage = "http://haskell-servant.readthedocs.org/"; 31 | description = "automatical derivation of querying functions for servant webservices"; 32 | license = stdenv.lib.licenses.bsd3; 33 | } 34 | -------------------------------------------------------------------------------- /nix/haskell/servant-server/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, aeson, attoparsec, base, base-compat 2 | , base64-bytestring, bytestring, Cabal, cabal-doctest, containers 3 | , directory, doctest, exceptions, filemanip, filepath, hspec 4 | , hspec-wai, http-api-data, http-types, monad-control, mtl, network 5 | , network-uri, parsec, QuickCheck, resourcet, safe, servant 6 | , should-not-typecheck, split, stdenv, string-conversions 7 | , system-filepath, tagged, temporary, text, transformers 8 | , transformers-base, transformers-compat, wai, wai-app-static 9 | , wai-extra, warp, word8, fetchFromGitHub 10 | }: 11 | mkDerivation { 12 | pname = "servant-server"; 13 | version = "0.11"; 14 | src = (fetchFromGitHub { 15 | owner = "LumiGuide"; 16 | repo = "servant"; 17 | sha256 = "08b5z96v1b3izs2rd2fra35w03kb69wb9r68ia2r489kbynz19ch"; 18 | rev = "ce355147d0bf3d1eefc1dc049f1cfa3f008d38c9"; 19 | }) + "/servant-server"; 20 | isLibrary = true; 21 | isExecutable = true; 22 | setupHaskellDepends = [ base Cabal cabal-doctest ]; 23 | libraryHaskellDepends = [ 24 | aeson attoparsec base base-compat base64-bytestring bytestring 25 | containers exceptions filepath http-api-data http-types 26 | monad-control mtl network network-uri resourcet safe servant split 27 | string-conversions system-filepath tagged text transformers 28 | transformers-base transformers-compat wai wai-app-static warp word8 29 | ]; 30 | executableHaskellDepends = [ aeson base servant text wai warp ]; 31 | testHaskellDepends = [ 32 | aeson base base-compat base64-bytestring bytestring directory 33 | doctest exceptions filemanip filepath hspec hspec-wai http-types 34 | mtl network parsec QuickCheck resourcet safe servant 35 | should-not-typecheck string-conversions temporary text transformers 36 | transformers-compat wai wai-extra warp 37 | ]; 38 | homepage = "http://haskell-servant.readthedocs.org/"; 39 | description = "A family of combinators for defining webservices APIs and serving them"; 40 | license = stdenv.lib.licenses.bsd3; 41 | } 42 | -------------------------------------------------------------------------------- /nix/haskell/servant-swagger/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, aeson, aeson-qq, base, bytestring, Cabal 2 | , cabal-doctest, directory, doctest, filepath, hspec, http-media 3 | , insert-ordered-containers, lens, QuickCheck, servant, stdenv 4 | , swagger2, text, time, unordered-containers 5 | }: 6 | mkDerivation { 7 | pname = "servant-swagger"; 8 | version = "1.1.3.1"; 9 | sha256 = "0n5vvrxg1lllkm385g0jd2j5bsr21bcibwn5szdpn6r5yh2mvn78"; 10 | setupHaskellDepends = [ base Cabal cabal-doctest ]; 11 | libraryHaskellDepends = [ 12 | aeson base bytestring hspec http-media insert-ordered-containers 13 | lens QuickCheck servant swagger2 text unordered-containers 14 | ]; 15 | testHaskellDepends = [ 16 | aeson aeson-qq base directory doctest filepath hspec lens 17 | QuickCheck servant swagger2 text time 18 | ]; 19 | homepage = "https://github.com/haskell-servant/servant-swagger"; 20 | description = "Generate Swagger specification for your servant API"; 21 | license = stdenv.lib.licenses.bsd3; 22 | 23 | # Tests fail to compile 24 | doCheck = false; 25 | } 26 | -------------------------------------------------------------------------------- /nix/haskell/servant/default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, aeson, aeson-compat, attoparsec, base, base-compat 2 | , bytestring, Cabal, cabal-doctest, case-insensitive, directory 3 | , doctest, filemanip, filepath, hspec, http-api-data, http-media 4 | , http-types, mmorph, mtl, natural-transformation, network-uri 5 | , QuickCheck, quickcheck-instances, stdenv, string-conversions 6 | , tagged, text, url, vault, fetchFromGitHub 7 | }: 8 | mkDerivation { 9 | pname = "servant"; 10 | version = "0.11"; 11 | src = (fetchFromGitHub { 12 | owner = "LumiGuide"; 13 | repo = "servant"; 14 | sha256 = "08b5z96v1b3izs2rd2fra35w03kb69wb9r68ia2r489kbynz19ch"; 15 | rev = "ce355147d0bf3d1eefc1dc049f1cfa3f008d38c9"; 16 | }) + "/servant"; 17 | setupHaskellDepends = [ base Cabal cabal-doctest ]; 18 | libraryHaskellDepends = [ 19 | aeson attoparsec base base-compat bytestring case-insensitive 20 | http-api-data http-media http-types mmorph mtl 21 | natural-transformation network-uri string-conversions tagged text 22 | vault 23 | ]; 24 | testHaskellDepends = [ 25 | aeson aeson-compat attoparsec base base-compat bytestring directory 26 | doctest filemanip filepath hspec QuickCheck quickcheck-instances 27 | string-conversions text url 28 | ]; 29 | homepage = "http://haskell-servant.readthedocs.org/"; 30 | description = "A family of combinators for defining webservices APIs"; 31 | license = stdenv.lib.licenses.bsd3; 32 | } 33 | -------------------------------------------------------------------------------- /nix/overlay.nix: -------------------------------------------------------------------------------- 1 | # This Nix expression defines an overlay which is a way to extend or override 2 | # the packages in with your own packages. 3 | # 4 | # For more information about overlays see: 5 | # https://nixos.org/nixpkgs/manual/#chap-overlays 6 | # 7 | # The first argument `self` corresponds to the final package set. You should use 8 | # this set for the dependencies of all packages specified in your overlay. 9 | # 10 | # The second argument `super` corresponds to the result of the evaluation of the 11 | # previous stages of Nixpkgs. It does not contain any of the packages added by 12 | # the current overlay, nor any of the following overlays. This set should be 13 | # used either to refer to packages you wish to override, or to access functions 14 | # defined in Nixpkgs. 15 | 16 | self: super: 17 | 18 | let 19 | # Note that we extend both the `haskellPackages` set as well as 20 | # `haskell.packages.ghcjsHEAD` set with the same overrides. That's why we give 21 | # it a name here. 22 | haskellOverrides = import ./haskell-overrides.nix self; 23 | in 24 | 25 | { 26 | haskellPackages = super.haskellPackages.override haskellOverrides; 27 | 28 | haskell = super.haskell // { 29 | packages = super.haskell.packages // { 30 | ghcjsHEAD = super.haskell.packages.ghcjsHEAD.override haskellOverrides; 31 | }; 32 | }; 33 | 34 | # Here we're extending with the attribute `nixtodo.frontend-static`. 35 | # This is a derivation which applies the closure-compiler to the 36 | # `nixtodo-frontend` GHCJS package to compress and optimize it. This is the 37 | # package that we're actually going to deploy to production. 38 | nixtodo = { 39 | frontend-static = 40 | let pkg = self.haskell.packages.ghcjsHEAD.nixtodo-frontend; 41 | in self.runCommand "${pkg.pname}-static" rec { 42 | indexTemplate = "${pkg}/index.html.mustache"; 43 | static = "${pkg}/static"; 44 | jsFiles = "all.js"; 45 | optimized = self.stdenv.mkDerivation rec { 46 | inherit jsFiles pkg; 47 | 48 | name = "${pkg.pname}-optimized-${pkg.version}"; 49 | dir = "bin/${pkg.pname}.jsexe"; 50 | 51 | buildInputs = [ self.closurecompiler ]; 52 | buildCommand = '' 53 | source $stdenv/setup 54 | 55 | mkdir -p "$out/$dir" 56 | 57 | for jsFile in ''${jsFiles[@]}; do 58 | closure-compiler "$pkg/$dir/$jsFile" \ 59 | --warning_level=QUIET \ 60 | --compilation_level=SIMPLE_OPTIMIZATIONS \ 61 | > "$out/$dir/$jsFile" 62 | done 63 | ''; 64 | }; 65 | } '' 66 | mkdir -p $out/static/js 67 | 68 | cp $indexTemplate $out/index.html.mustache 69 | 70 | for jsFile in ''${jsFiles[@]}; do 71 | cp "$optimized/bin/${pkg.pname}.jsexe/$jsFile" "$out/static/js/$jsFile" 72 | done 73 | 74 | cp -r $static/* $out/static/ # */ 75 | ''; 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /nixpkgs.nix: -------------------------------------------------------------------------------- 1 | # This expression returns the /nix/store path to our version of nixpkgs. 2 | # It ensures that all engineers use the same revision of nixpkgs. 3 | # 4 | # This technique was inspired by the article: 5 | # 6 | # Reproducible Development Environments by Rok Garbas 7 | # https://garbas.si/2015/reproducible-development-environments.html 8 | 9 | let # Note that we depend on a in our NIX_PATH. This nixpkgs is only 10 | # used to access the `fetchFromGitHub` function which is used to fetch the 11 | # desired version of nixpkgs. 12 | pkgs = import {}; 13 | 14 | nixpkgs = pkgs.fetchFromGitHub { 15 | owner = "NixOS"; 16 | repo = "nixpkgs"; 17 | # Points to a recent commit on the nixos-17.09-small branch. 18 | rev = "3c0ea4fa4b931501212e1cf2708ea67cc3dbbcdf"; 19 | sha256 = "11iwvnbwns4gdfakjr8qxzzcbzb718chlihxrm2fypg91k1kh7ws"; 20 | }; 21 | 22 | # Often times you need some modifications to be made to nixpkgs. For example 23 | # you may have created a Pull Request that makes a change to some NixOS 24 | # module and it hasn't been merged yet. In those cases you can take the 25 | # corresponding patch and apply it to the nixpkgs that we've checked out 26 | # above. 27 | patches = [ 28 | # # Adds the postage package and NixOS module 29 | # (pkgs.fetchpatch { 30 | # url = "https://github.com/NixOS/nixpkgs/commit/943c78b10d8ed4418dbb6fb9a89e6f416af511d5.patch"; 31 | # sha256 = "13vhrkihbw7nrwplxlhfvwm493h53y7yzs8j5nsxnhv70hhpiwc4"; 32 | # }) 33 | ]; 34 | 35 | in pkgs.runCommand ("nixpkgs-" + nixpkgs.rev) {inherit nixpkgs patches; } '' 36 | cp -r $nixpkgs $out 37 | chmod -R +w $out 38 | for p in $patches ; do 39 | echo "Applying patch $p" 40 | patch -d $out -p1 < "$p" 41 | done 42 | '' 43 | -------------------------------------------------------------------------------- /release.nix: -------------------------------------------------------------------------------- 1 | # This file defines the jobs that hydra.nixtodo.com should build. 2 | # The jobs currently consist of the nixtodo Haskell packages. 3 | 4 | let 5 | pkgs = import ./default.nix; 6 | in { 7 | nixtodo-api = pkgs.haskellPackages.nixtodo-api; 8 | nixtodo-api-client = pkgs.haskell.packages.ghcjsHEAD.nixtodo-api-client; 9 | nixtodo-backend = pkgs.haskellPackages.nixtodo-backend; 10 | nixtodo-frontend = pkgs.haskell.packages.ghcjsHEAD.nixtodo-frontend; 11 | } 12 | -------------------------------------------------------------------------------- /secrets/README.md: -------------------------------------------------------------------------------- 1 | This directory contains secret files. It would be unsafe to store them as 2 | plaintext so we encrypt them using the [git-crypt][1] tool. Execute the 3 | following command to decrypt these files: 4 | 5 | git-crypt unlock 6 | 7 | Get the `` from one of the maintainers who can create it by executing 8 | the following command: 9 | 10 | git-crypt export-key 11 | 12 | Note that the `.gitattributes` file in the root of the lumi repository specifies 13 | which files should and shouldn't be encrypted. 14 | 15 | [1]: https://www.agwa.name/projects/git-crypt/ 16 | -------------------------------------------------------------------------------- /secrets/cache.nixtodo.com-secret-key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/cache.nixtodo.com-secret-key -------------------------------------------------------------------------------- /secrets/devops-hydra-password: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/devops-hydra-password -------------------------------------------------------------------------------- /secrets/dhparams.pem: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/dhparams.pem -------------------------------------------------------------------------------- /secrets/hydra-github.id_rsa: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/hydra-github.id_rsa -------------------------------------------------------------------------------- /secrets/hydra-github.id_rsa.pub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/hydra-github.id_rsa.pub -------------------------------------------------------------------------------- /secrets/postgresql-nixtodo-role-password: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/postgresql-nixtodo-role-password -------------------------------------------------------------------------------- /secrets/state.nixops: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basvandijk/nixtodo/b22cc584208f74258a697e2a4143eb068af55aec/secrets/state.nixops -------------------------------------------------------------------------------- /spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled" : 1, 3 | "hidden" : false, 4 | "description" : "Jobsets", 5 | "nixexprinput" : "nixtodo", 6 | "nixexprpath" : "jobset.nix", 7 | "checkinterval" : 300, 8 | "schedulingshares" : 100, 9 | "enableemail" : false, 10 | "emailoverride" : "", 11 | "keepnr" : 1, 12 | "inputs": { 13 | "nixpkgs": { "type": "git", "value": "git@github.com:NixOS/nixpkgs-channels.git nixos-17.09-small", "emailresponsible": false }, 14 | "nixtodo": { "type": "git", "value": "git@github.com:basvandijk/nixtodo.git master", "emailresponsible": false } 15 | } 16 | } 17 | --------------------------------------------------------------------------------