diff --git a/.envrc b/.envrc index cffc922b0..a7d0f9482 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ use flake . --impure +export IHP_LIB=lib/IHP/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index ba0123cec..e8d960f41 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ devenv.local.nix # nix build output links result* + +.idea diff --git a/Guide/database.markdown b/Guide/database.markdown index 954c42328..ed1c145c6 100644 --- a/Guide/database.markdown +++ b/Guide/database.markdown @@ -765,18 +765,10 @@ Similarly as for renaming, deleting a column currently won't work automatically 1. Delete your column in the Schema Designer 2. Delete the column from the database by executing `ALTER TABLE tablename DROP COLUMN colname` -### Alternate Method - -There's always more than one way. This is another. - -1. Make changes in the Schema Designer -2. Click `Save DB to Fixtures` in the Schema Designer (Use the arrow next to the `Update DB` button to see this option) -3. Edit `Fixtures.sql` to your heart's content. -4. Click `Push to DB` in the Schema Designer (Use the arrow next to the `Update DB` button to see this option) ### Migrations In Production -IHP currently has no built-in migration system yet. We're still experimenting with a great way to solve this. Until then, the recommended approach used by digitally induced is to manually migrate your database using DDL statements as shown above. +See the [Migrations guide](https://ihp.digitallyinduced.com/Guide/database-migrations.html). ## Supported Database Types @@ -848,3 +840,16 @@ action CreateUserAction = do redirectTo NewSessionAction ``` + +## Unique Constraints + +It's possible to use the UI to set the unique constraint on a column. However, sometimes you might want to add a unique constraint on multiple columns. This can be done by adding a unique constraint to the `Schema.sql` file. For example, to add a unique constraint on the `email` and `username` columns of the `users` table, you would add the following to the `Schema.sql` file: + +```sql +CREATE TABLE users ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + username TEXT NOT NULL, + UNIQUE (email, username) +); +``` diff --git a/Guide/deployment.markdown b/Guide/deployment.markdown index 643e875b1..6bc69feb2 100644 --- a/Guide/deployment.markdown +++ b/Guide/deployment.markdown @@ -10,19 +10,151 @@ IHP comes with a standard command called `deploy-to-nixos`. This tool is a littl AWS EC2 is a good choice for deploying IHP in a professional setup. -### Creating a new EC2 Instance +### AWS infrastructure preparation + +#### Creating a new EC2 Instance Start a new EC2 instance and use the official NixOS AMI `NixOS-23.05.426.afc48694f2a-x86_64-linux`. You can find the latest NixOS AMI at https://nixos.org/download#nixos-amazon Example steps: - Visit [EC2 creation page](https://eu-west-1.console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstances:) in your desired region. - - Select AMI by name, it will appear under "Community AMIs" after searching by name. - - Select at least a `t3a.small` instance size to have enough RAM for the compilation - - Specify a generous root disk volume. By nature NixOS can consume lots of disk space as you trial-and-error your application deployment. As a minimum, we advise 60 GiB + - Select AMI by name, it will appear under "Community AMIs" after searching by name (there can be a slight delay before the result appears as it searches in all community AMIs). + - Select at least a `t3a.small` instance size to have enough RAM for the compilation. For a real-world application, chances are that you need `t3a.medium` to successfully compile it. + - Specify a generous root disk volume. By nature NixOS can consume lots of disk space as you trial-and-error your application deployment. As a minimum, we advise 60 GiB. - Under `Network settings`, allow SSH traffic from your IP address only, allow HTTPS and HTTP traffic from the internet. Due to the certificate validation for Let's Encrypt, even if your application does not need to have it, allow HTTP too. - Make sure to attach SSH keys to the instance at creation time, that is available locally, so you can SSH to the EC2 instance without password later. + - Either before creating the EC2 instance, you import your existing keypair to [EC2 Key Pairs](https://us-east-1.console.aws.amazon.com/ec2/home?region=eu-west-1#ImportKeyPair:), then you should select it at the EC2 creation page. + - Or let AWS create one on-the-fly: ![image](https://github.com/digitallyinduced/ihp/assets/114076/317b022a-ad6e-43ae-931d-8710db0b711c) . Afterwards, you will be able to download the private key file, later on it is referred as `ihp-app.pem` in this documentation. + +#### (Optional) Creating an RDS Instance + +For production systems, it is advised to use a fully managed PostgreSQL instance, it can be multi-region, fault tolerant, but most of all, +daily backups happen automatically with configurable retention. + +To switch from the local PostgreSQL instance to a managed one (you can do it after or before the initial deployment), you can execute the following steps: + - Visit [RDS creation page](https://us-east-1.console.aws.amazon.com/rds/home?region=eu-west-1#launch-dbinstance:) in your desired region. + - Select PostgreSQL as the Engine Type. + - Select a compatible Engine Version, there are good chances that the very last version will fit. + - At Templates, Choose `Free Tier` for any non-live environments, `Production` for the live environment. + - (Optional) Choose `Auto generate password` for having a secure master password. + - Choose `Connect to an EC2 compute resource` and select your already existing EC2 instance. + - Then you can `Create database`. This process is slow, check back in 10 minutes or so afterward. Note down the auto-generated password. + - Edit your `flake.nix`, under `flake.nixosConfigurations."ihp-app".services.ihp`, you can specify the database URL like: `databaseUrl = lib.mkForce "postgresql://postgres:YOUR-PASSWORD@YOUR-HOSTNAME.amatonaws.com/postgres";`. You can find the proper hostname after the initialization is complete, on the RDS instance detail page. + - `pg_dump --no-owner --no-acl` your existing local database on the EC2 instance directly, and then, you can load it to the newly created instance via `pgsql`. `deploy-to-nixos` won't populate the initial schema at an existing remote database, that's why dumping, `scp d`ing and loading it via `psql` is necessary. + +#### (Optional) Creating an S3 bucket + +If your application needs to store files, on AWS, those should use an S3 bucket for that. + +Infrastructure-side preparation: + - Visit the [S3 creation page](https://s3.console.aws.amazon.com/s3/bucket/create?region=eu-west-1) and create a bucket in the same region..If objects should or should not be public, it's up to the application's business requirements. The S3 [ARN](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html) from the S3 details page should be noted down. + - Create an new IAM user for the S3 access. Create an [AWS access key](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) for that IAM user. + - For that user, attach a policy that allows access to the bucket, for example: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor1", + "Effect": "Allow", + "Action": "s3:*", + "Resource": [ + "YOUR-BUCKET-ARN", + "YOUR-BUCKET-ARN/*" + ] + } + ] +} +``` + - See the [Storage guide](https://ihp.digitallyinduced.com/Guide/file-storage.html#s3) on how to use the access key. + + If your application requires so, make the S3 bucket publicly available. + - Go to https://s3.console.aws.amazon.com/s3/buckets/YOUR-BUCKET?region=eu-west-1&bucketType=general&tab=permissions (permissions tab of the S3 bucket) + - Set `Block all public access` to entirely off. + - Set a bucket policy like this: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "YOUR-BUCKET-ARN/*" + } + ] +} +``` + - Test the access by locating a file in a bucket under Objects and "Copy S3 URI" for it. + +#### (Optional) Connecting CloudWatch + +For a production system, logging is essential, so you are informed about anomalies before customer complaints, or you are able to provide an evidence for an incident and so on. + +Mind the region of your EC2 instance for these steps. + +- [Create a CloudWatch log group](https://eu-west-1.console.aws.amazon.com/cloudwatch/home?region=eu-west-1#logsV2:log-groups/create-log-group), note down the ARN. +- Create a log stream inside the previously created log group, for instance `in`. +- Create an IAM user with an access key and secret with the following policy: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams" + ], + "Resource": [ + "[YOUR-GROUP-ARN]", + "[YOUR-GROUP-ARN]:*" + ] + } + ] +} +``` +- Configure the `services.vector` part in your `flake.nix` to activate logging: +``` +services.vector = { + enable = true; + journaldAccess = true; + settings = { + sources.journald = { + type = "journald"; + include_units = ["app.service" "nginx.service" "worker.service"]; + }; + transforms.remap_remove_specific_keys = { + type = "remap"; + inputs = ["journald"]; + source = '' + del(._STREAM_ID) + del(._SYSTEMD_UNIT) + del(._BOOT_ID) + del(.source_type) + ''; + }; + sinks.out = { + auth = { + access_key_id = "YOUR-IAM-ACCESS-KEY"; + secret_access_key = "YOUR-IAM-ACCESS-KEY"; + }; + inputs = ["remap_remove_specific_keys"]; + type = "aws_cloudwatch_logs"; + compression = "gzip"; + encoding.codec = "json"; + region = "us-east-1"; + group_name = "tpp-qa"; + stream_name = "in"; + }; + }; +}; +``` +- Review the incoming log entries, adjust remapping accordingly. You might want to remove or transform more entries to make the logs useful for alerts or accountability. -### Connecting to the EC2 Instance +### Connecting to the EC2 / Virtual Machine Instance After you've created the instance, configure your local SSH settings to point to the instance. diff --git a/Guide/editors.markdown b/Guide/editors.markdown index 26f78b3c4..76de50f3e 100644 --- a/Guide/editors.markdown +++ b/Guide/editors.markdown @@ -149,6 +149,70 @@ and `chmod +x ~/bin/emacs-line`, then export this env var in your shell (e.g. in export IHP_EDITOR="$HOME/bin/emacs-line" ``` +Another useful package set that integrates with lsp/lsp-ui and loads the default nix environment from direnv as well as removing [common flycheck issue](https://github.com/joncol/dotfiles/blob/master/homedir/.emacs.d/init.el). +This config also adds a jump to definition for functions bound to "C-c p": + +```emacs +(use-package direnv + :defer + :custom + (direnv-always-show-summary nil) + :config + (direnv-mode)) +(use-package lsp-mode + :custom + (lsp-lens-enable nil) + (lsp-enable-symbol-highlighting nil) + + :hook + (lsp-mode . lsp-enable-which-key-integration) + + :config + ;; This is to make `lsp-mode' work with `direnv' and pick up the correct + ;; version of GHC. + (advice-add 'lsp :before #'direnv-update-environment) + (setq lsp-modeline-code-actions-enable nil)) + +(use-package lsp-ui + :hook (prog-mode . lsp-ui-mode) + :bind (("C-c p" . lsp-ui-peek-find-definitions)) + :config + (setq lsp-ui-doc-position 'bottom)) + +;; (add-hook 'haskell-mode-hook #'lsp) +(use-package flycheck-haskell + ;; Disabling this package, since it only gives error: + ;; "Reading Haskell configuration failed with exit code Segmentation fault and + ;; output:", when trying to run it in Nix/direnv setup. + :disabled + :hook (haskell-mode . flycheck-haskell-setup)) + +(add-hook 'haskell-mode-hook + (lambda () + (rainbow-mode -1) +;; we aren't evil: +;; (evil-leader/set-key "x h" 'haskell-hoogle) +;; (setq evil-shift-width 2) + (define-key haskell-mode-map (kbd "C-c C-c C-s") + 'haskell-mode-stylish-buffer) + (bind-key (kbd "C-c C-c C-a") 'haskell-sort-imports) + (setq haskell-auto-insert-module-format-string + "module %s\n () where\n\n") + (haskell-auto-insert-module-template) + (smartparens-mode) + (sp-local-pair 'haskell-mode "{" "}") + (setq haskell-hoogle-command nil) + (ligature-mode))) + +(use-package lsp-haskell + :hook ((haskell-mode . lsp-deferred) + (haskell-literate-mode . lsp-deferred)) + :custom + (lsp-haskell-server-path "haskell-language-server")) + +(use-package haskell-mode + :defer) +``` ## Using IHP with Vim / NeoVim diff --git a/Guide/search/package-lock.json b/Guide/search/package-lock.json index c41bd2464..4e84da9b5 100644 --- a/Guide/search/package-lock.json +++ b/Guide/search/package-lock.json @@ -177,6 +177,29 @@ "react-dom": ">= 16.8.0 < 18.0.0" } }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "peer": true + }, + "node_modules/@types/react": { + "version": "17.0.71", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.71.tgz", + "integrity": "sha512-lfqOu9mp16nmaGRrS8deS2Taqhd5Ih0o92Te5Ws6I1py4ytHBcXLqh0YIqVsViqwVI5f+haiFM6hju814BzcmA==", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "peer": true + }, "node_modules/algoliasearch": { "version": "4.10.5", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.10.5.tgz", @@ -198,6 +221,12 @@ "@algolia/transporter": "4.10.5" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "peer": true + }, "node_modules/esbuild": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.2.tgz", @@ -629,6 +658,29 @@ "algoliasearch": "^4.0.0" } }, + "@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "peer": true + }, + "@types/react": { + "version": "17.0.71", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.71.tgz", + "integrity": "sha512-lfqOu9mp16nmaGRrS8deS2Taqhd5Ih0o92Te5Ws6I1py4ytHBcXLqh0YIqVsViqwVI5f+haiFM6hju814BzcmA==", + "peer": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "peer": true + }, "algoliasearch": { "version": "4.10.5", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.10.5.tgz", @@ -650,6 +702,12 @@ "@algolia/transporter": "4.10.5" } }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "peer": true + }, "esbuild": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.13.2.tgz", diff --git a/Guide/view.markdown b/Guide/view.markdown index 6a6f4419d..4e326c972 100644 --- a/Guide/view.markdown +++ b/Guide/view.markdown @@ -181,6 +181,58 @@ Now the `company` variable can be used to read the current user's company across Use [`theRequest`](https://ihp.digitallyinduced.com/api-docs/IHP-ViewSupport.html#v:theRequest) to access the current [WAI request](https://hackage.haskell.org/package/wai-3.2.2.1/docs/Network-Wai.html). +### Passing JSON to the View + +You might need to pass JSON values to the view, so later you could have a JS script to read it. You should use Aeson's `toJSON` function to convert your data to JSON and then pass it to the view. Let's say you have a `Posts` controller and `Post`s records that you want to print in the console. + +```haskell +-- Web/Controller/Posts.hs +instance Controller PostsController where + action PostsAction = do + posts <- query @Post |> fetch + + render IndexView { .. } + +``` + +Then in the view, you can access the JSON data like this: + +```haskell +-- Web/View/Posts/Index.hs + +module Web.View.Posts.Index where +import Web.View.Prelude + +-- Add Aeson import. +import Data.Aeson (encode) + +data IndexView = IndexView { posts :: [Post] } + +instance View IndexView where + html IndexView { .. } = [hsx| +
Routing failed with: {tshow exception}
@@ -329,14 +331,12 @@ handleRouterException exception = let title = case value of Just value -> [hsx|Expected {expectedType} for field {field} but got{value}|] Nothing -> [hsx|The action was called without the required
{field}parameter|] - let RequestContext { respond } = ?context respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just Router.BadType { expectedType, value = Just value, field } -> do let errorMessage = [hsx|
Routing failed with: {tshow exception}
|] let title = [hsx|Query parameter{field}needs to be a
{expectedType}but got
{value}|] - let RequestContext { respond } = ?context respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) _ -> case fromException exception of Just Router.UnexpectedMethodException { allowedMethods = [Router.DELETE], method = Router.GET } -> do @@ -371,7 +371,6 @@ handleRouterException exception = |] let title = [hsx|Action was called from a GET request, but needs to be called as a DELETE request|] - let RequestContext { respond } = ?context respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just Router.UnexpectedMethodException { allowedMethods = [Router.POST], method = Router.GET } -> do let errorMessage = [hsx| @@ -386,7 +385,6 @@ handleRouterException exception = |] let title = [hsx|Action was called from a GET request, but needs to be called as a POST request|] - let RequestContext { respond } = ?context respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) Just Router.UnexpectedMethodException { allowedMethods, method } -> do let errorMessage = [hsx| @@ -397,7 +395,6 @@ handleRouterException exception = |] let title = [hsx|Action was called with a {method} request, but needs to be called with one of these request methods:
{allowedMethods}|] - let RequestContext { respond } = ?context respond $ responseBuilder status400 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) _ -> do let errorMessage = [hsx| @@ -407,7 +404,6 @@ handleRouterException exception =
Are you trying to do a DELETE action, but your link is missing class="js-delete"?
|] let title = H.text "Routing failed" - let RequestContext { respond } = ?context respond $ responseBuilder status500 [(hContentType, "text/html")] (Blaze.renderHtmlBuilder (renderError title errorMessage)) diff --git a/IHP/IDE/FileWatcher.hs b/IHP/IDE/FileWatcher.hs index 7713e50d1..d380eb9d2 100644 --- a/IHP/IDE/FileWatcher.hs +++ b/IHP/IDE/FileWatcher.hs @@ -9,7 +9,6 @@ import System.Directory (listDirectory, doesDirectoryExist) import qualified Data.Map as Map import qualified System.FSNotify as FS import IHP.IDE.Types -import qualified Data.Time.Clock as Clock import qualified Data.List as List import IHP.IDE.LiveReloadNotificationServer (notifyAssetChange) import qualified Control.Debounce as Debounce diff --git a/IHP/IDE/ToolServer.hs b/IHP/IDE/ToolServer.hs index 722d58478..2f7d09747 100644 --- a/IHP/IDE/ToolServer.hs +++ b/IHP/IDE/ToolServer.hs @@ -6,13 +6,11 @@ import qualified Network.Wai.Handler.Warp as Warp import IHP.IDE.Types import IHP.IDE.PortConfig import qualified IHP.ControllerSupport as ControllerSupport -import qualified IHP.ErrorController as ErrorController import IHP.ApplicationContext import IHP.ModelSupport import IHP.RouterSupport hiding (get) import Network.Wai.Session.ClientSession (clientsessionStore) import qualified Web.ClientSession as ClientSession -import qualified Data.Vault.Lazy as Vault import Network.Wai.Middleware.MethodOverridePost (methodOverridePost) import Network.Wai.Session (withSession) import qualified Network.WebSockets as Websocket @@ -37,6 +35,7 @@ import qualified System.Process as Process import System.Info import qualified IHP.EnvVar as EnvVar import qualified IHP.AutoRefresh.Types as AutoRefresh +import qualified IHP.AutoRefresh as AutoRefresh import IHP.Controller.Context import qualified IHP.IDE.ToolServer.Layout as Layout import IHP.Controller.Layout @@ -48,6 +47,7 @@ import qualified IHP.PGListener as PGListener import qualified Network.Wai.Application.Static as Static import qualified WaiAppStatic.Types as Static import IHP.Controller.NotFound (handleNotFound) +import IHP.Controller.Session (sessionVaultKey) withToolServer :: (?context :: Context) => IO () -> IO () withToolServer inner = withAsyncBound async (\_ -> inner) @@ -71,21 +71,18 @@ startToolServer' port isDebugMode = do Just baseUrl -> Config.option $ Config.BaseUrl baseUrl Nothing -> pure () - session <- Vault.newKey store <- fmap clientsessionStore (ClientSession.getKey "Config/client_session_key.aes") - let sessionMiddleware :: Wai.Middleware = withSession store "SESSION" (frameworkConfig.sessionCookie) session + let sessionMiddleware :: Wai.Middleware = withSession store "SESSION" (frameworkConfig.sessionCookie) sessionVaultKey let modelContext = notConnectedModelContext undefined pgListener <- PGListener.init modelContext autoRefreshServer <- newIORef (AutoRefresh.newAutoRefreshServer pgListener) staticApp <- initStaticApp - let applicationContext = ApplicationContext { modelContext, session, autoRefreshServer, frameworkConfig, pgListener } + let applicationContext = ApplicationContext { modelContext, autoRefreshServer, frameworkConfig, pgListener } let toolServerApplication = ToolServerApplication { devServerContext = ?context } let application :: Wai.Application = \request respond -> do let ?applicationContext = applicationContext - requestContext <- ControllerSupport.createRequestContext applicationContext request respond - let ?context = requestContext - frontControllerToWAIApp toolServerApplication [] (staticApp request respond) + frontControllerToWAIApp @ToolServerApplication @AutoRefresh.AutoRefreshWSApp (\app -> app) toolServerApplication staticApp request respond let openAppUrl = openUrl ("http://localhost:" <> tshow port <> "/") let warpSettings = Warp.defaultSettings @@ -157,4 +154,4 @@ readDatabaseNeedsMigration :: (?context :: ControllerContext) => IO Bool readDatabaseNeedsMigration = do context <- theDevServerContext state <- readIORef (context.appStateRef) - readIORef (state.databaseNeedsMigration) + readIORef (state.databaseNeedsMigration) \ No newline at end of file diff --git a/IHP/RouterSupport.hs b/IHP/RouterSupport.hs index 2e88ea0be..96da10814 100644 --- a/IHP/RouterSupport.hs +++ b/IHP/RouterSupport.hs @@ -84,37 +84,38 @@ runAction' :: forall application controller . ( Controller controller , ?applicationContext :: ApplicationContext - , ?context :: RequestContext , InitControllerContext application , ?application :: application , Typeable application , Typeable controller ) - => controller -> (TMap.TMap -> TMap.TMap) -> IO ResponseReceived -runAction' controller contextSetter = do + => controller -> (TMap.TMap -> TMap.TMap) -> Application +runAction' controller contextSetter request respond = do let ?modelContext = ApplicationContext.modelContext ?applicationContext - let ?requestContext = ?context + requestContext <- createRequestContext ?applicationContext request respond + let ?context = requestContext + let ?requestContext = requestContext contextOrErrorResponse <- newContextForAction contextSetter controller case contextOrErrorResponse of Left res -> res Right context -> let ?context = context in runAction controller {-# INLINABLE runAction' #-} -type RouteParseResult = IO (TMap.TMap -> TMap.TMap, (TMap.TMap -> TMap.TMap) -> IO ResponseReceived) +type RouteParseResult = IO (TMap.TMap -> TMap.TMap, (TMap.TMap -> TMap.TMap) -> Application) type RouteParser = Parser (RouteParseResult) -toRouteParser :: Parser (IO ResponseReceived) -> RouteParser +toRouteParser :: Parser Application -> RouteParser toRouteParser parser = do controller <- parser pure $ pure (\t -> t, \_ -> controller) -toRouteParser' :: Parser ((TMap.TMap -> TMap.TMap) -> IO ResponseReceived) -> RouteParser +toRouteParser' :: Parser ((TMap.TMap -> TMap.TMap) -> Application) -> RouteParser toRouteParser' parser = do controller <- parser pure $ pure (\t -> t, controller) -toRouteParseResult :: IO ResponseReceived -> RouteParseResult -toRouteParseResult ioResponseReceived = pure (\t -> t, \_ -> ioResponseReceived) +toRouteParseResult :: Application -> RouteParseResult +toRouteParseResult application = pure (\t -> t, \_ -> application) class FrontController application where controllers @@ -132,8 +133,8 @@ defaultRouter => [RouteParser] -> RouteParser defaultRouter additionalControllers = do let allControllers = controllers <> additionalControllers - ioResponseReceived <- choice $ map (\r -> r <* endOfInput) allControllers - pure ioResponseReceived + applications <- choice $ map (\r -> r <* endOfInput) allControllers + pure applications {-# INLINABLE defaultRouter #-} class HasPath controller where @@ -835,13 +836,20 @@ startPage action = get (ByteString.pack (actionPrefix @action)) action withPrefix prefix routes = string prefix >> choice (map (\r -> r <* endOfInput) routes) {-# INLINABLE withPrefix #-} -runApp :: (?applicationContext :: ApplicationContext, ?context :: RequestContext) => RouteParser -> IO ResponseReceived -> IO ResponseReceived -runApp routes notFoundAction = do - let path = ?context.request.rawPathInfo - handleException :: SomeException -> IO (Either String (IO ResponseReceived)) +frontControllerToWAIApp :: forall app (autoRefreshApp :: Type). (?applicationContext :: ApplicationContext, FrontController app, WSApp autoRefreshApp, Typeable autoRefreshApp, InitControllerContext ()) => Middleware -> app -> Application -> Application +frontControllerToWAIApp middleware application notFoundAction request respond = do + let requestContext = RequestContext { request, respond, requestBody = FormBody { params = [], files = [] }, frameworkConfig = ?applicationContext.frameworkConfig } + + let ?context = requestContext + + let + path = request.rawPathInfo + handleException :: SomeException -> IO (Either String Application) handleException exception = pure $ Right $ ErrorController.handleRouterException exception - routedAction :: Either String (IO ResponseReceived) <- + routes = let ?application = application in router [let ?application = () in webSocketApp @autoRefreshApp] + + routedAction :: Either String Application <- (do res <- evaluate $ parseOnly (routes <* endOfInput) path case res of @@ -850,17 +858,10 @@ runApp routes notFoundAction = do (tmapSetter, controllerFn) <- io pure $ Right $ controllerFn $ tmapSetter ) - -- pure (undefined::IO ResponseReceived))) `Exception.catch` handleException case routedAction of - Left message -> notFoundAction - Right action -> action -{-# INLINABLE runApp #-} - -frontControllerToWAIApp :: forall app. (?applicationContext :: ApplicationContext, ?context :: RequestContext, FrontController app) => app -> [RouteParser] -> IO ResponseReceived -> IO ResponseReceived -frontControllerToWAIApp application additionalControllers notFoundAction = runApp defaultRouter notFoundAction - where - defaultRouter :: RouteParser = (let ?application = application in router additionalControllers) + Left message -> notFoundAction request respond + Right action -> (middleware action) request respond {-# INLINABLE frontControllerToWAIApp #-} mountFrontController :: forall frontController. (?applicationContext :: ApplicationContext, ?context :: RequestContext, FrontController frontController) => frontController -> RouteParser diff --git a/IHP/Server.hs b/IHP/Server.hs index ed93305a6..3899c4a5a 100644 --- a/IHP/Server.hs +++ b/IHP/Server.hs @@ -5,18 +5,16 @@ import IHP.Prelude import qualified Network.Wai.Handler.Warp as Warp import Network.Wai import Network.Wai.Middleware.MethodOverridePost (methodOverridePost) -import Network.Wai.Session (withSession, Session) +import Network.Wai.Session (withSession) import Network.Wai.Session.ClientSession (clientsessionStore) import qualified Web.ClientSession as ClientSession -import qualified Data.Vault.Lazy as Vault +import IHP.Controller.Session (sessionVaultKey) import IHP.ApplicationContext -import qualified IHP.ControllerSupport as ControllerSupport import qualified IHP.Environment as Env import qualified IHP.PGListener as PGListener import IHP.FrameworkConfig -import IHP.RouterSupport (frontControllerToWAIApp, FrontController, webSocketApp, webSocketAppWithCustomPath) -import IHP.ErrorController +import IHP.RouterSupport (frontControllerToWAIApp, FrontController) import qualified IHP.AutoRefresh as AutoRefresh import qualified IHP.AutoRefresh.Types as AutoRefresh import IHP.LibDir @@ -48,14 +46,12 @@ run configBuilder = do withInitalizers frameworkConfig modelContext do withPGListener \pgListener -> do - sessionVault <- Vault.newKey - autoRefreshServer <- newIORef (AutoRefresh.newAutoRefreshServer pgListener) let ?modelContext = modelContext - let ?applicationContext = ApplicationContext { modelContext = ?modelContext, session = sessionVault, autoRefreshServer, frameworkConfig, pgListener } + let ?applicationContext = ApplicationContext { modelContext = ?modelContext, autoRefreshServer, frameworkConfig, pgListener } - sessionMiddleware <- initSessionMiddleware sessionVault frameworkConfig + sessionMiddleware <- initSessionMiddleware frameworkConfig staticApp <- initStaticApp frameworkConfig let corsMiddleware = initCorsMiddleware frameworkConfig let requestLoggerMiddleware = frameworkConfig.requestLoggerMiddleware @@ -65,10 +61,9 @@ run configBuilder = do . runServer frameworkConfig . customMiddleware . corsMiddleware - . sessionMiddleware - . requestLoggerMiddleware . methodOverridePost - $ application staticApp + . sessionMiddleware + $ application staticApp requestLoggerMiddleware {-# INLINABLE run #-} @@ -99,7 +94,7 @@ initStaticApp frameworkConfig = do frameworkStaticDir = libDir <> "/static/" frameworkSettings = (Static.defaultWebAppSettings frameworkStaticDir) - { Static.ss404Handler = Just handleNotFound + { Static.ss404Handler = Just (frameworkConfig.requestLoggerMiddleware handleNotFound) , Static.ssMaxAge = maxAge } appSettings = (Static.defaultWebAppSettings "static/") @@ -109,8 +104,8 @@ initStaticApp frameworkConfig = do pure (Static.staticApp appSettings) -initSessionMiddleware :: Vault.Key (Session IO ByteString ByteString) -> FrameworkConfig -> IO Middleware -initSessionMiddleware sessionVault FrameworkConfig { sessionCookie } = do +initSessionMiddleware :: FrameworkConfig -> IO Middleware +initSessionMiddleware FrameworkConfig { sessionCookie } = do let path = "Config/client_session_key.aes" hasSessionSecretEnvVar <- EnvVar.hasEnvVar "IHP_SESSION_SECRET" @@ -119,7 +114,7 @@ initSessionMiddleware sessionVault FrameworkConfig { sessionCookie } = do if hasSessionSecretEnvVar || not doesConfigDirectoryExist then ClientSession.getKeyEnv "IHP_SESSION_SECRET" else ClientSession.getKey path - let sessionMiddleware :: Middleware = withSession store "SESSION" sessionCookie sessionVault + let sessionMiddleware :: Middleware = withSession store "SESSION" sessionCookie sessionVaultKey pure sessionMiddleware initCorsMiddleware :: FrameworkConfig -> Middleware @@ -127,16 +122,9 @@ initCorsMiddleware FrameworkConfig { corsResourcePolicy } = case corsResourcePol Just corsResourcePolicy -> Cors.cors (const (Just corsResourcePolicy)) Nothing -> id -application :: (FrontController RootApplication, ?applicationContext :: ApplicationContext) => Application -> Application -application staticApp request respond = do - requestContext <- ControllerSupport.createRequestContext ?applicationContext request respond - let ?context = requestContext - let builtinControllers = let ?application = () in - [ webSocketApp @AutoRefresh.AutoRefreshWSApp - , webSocketAppWithCustomPath @AutoRefresh.AutoRefreshWSApp "" -- For b.c. with older versions of ihp-auto-refresh.js - ] - - frontControllerToWAIApp RootApplication builtinControllers (staticApp request respond) +application :: (FrontController RootApplication, ?applicationContext :: ApplicationContext) => Application -> Middleware -> Application +application staticApp middleware request respond = do + frontControllerToWAIApp @RootApplication @AutoRefresh.AutoRefreshWSApp middleware RootApplication staticApp request respond {-# INLINABLE application #-} runServer :: (?applicationContext :: ApplicationContext) => FrameworkConfig -> Application -> IO () @@ -152,9 +140,6 @@ runServer FrameworkConfig { environment = Env.Production, appPort, exceptionTrac |> Warp.setPort appPort |> Warp.setOnException exceptionTracker.onException -instance ControllerSupport.InitControllerContext () where - initContext = pure () - withInitalizers :: FrameworkConfig -> ModelContext -> IO () -> IO () withInitalizers frameworkConfig modelContext continue = do let ?context = frameworkConfig diff --git a/IHP/Test/Mocking.hs b/IHP/Test/Mocking.hs index 0598b2abd..8582cf66a 100644 --- a/IHP/Test/Mocking.hs +++ b/IHP/Test/Mocking.hs @@ -33,6 +33,7 @@ import qualified Network.Wai.Session import qualified Data.Serialize as Serialize import qualified Control.Exception as Exception import qualified IHP.PGListener as PGListener +import IHP.Controller.Session (sessionVaultKey) type ContextParameters application = (?applicationContext :: ApplicationContext, ?context :: RequestContext, ?modelContext :: ModelContext, ?application :: application, InitControllerContext application, ?mocking :: MockContext application) @@ -58,17 +59,15 @@ withIHPApp application configBuilder hspecAction = do withTestDatabase \testDatabase -> do modelContext <- createModelContext dbPoolIdleTime dbPoolMaxConnections (testDatabase.url) logger - session <- Vault.newKey pgListener <- PGListener.init modelContext autoRefreshServer <- newIORef (AutoRefresh.newAutoRefreshServer pgListener) - let sessionVault = Vault.insert session mempty Vault.empty - let applicationContext = ApplicationContext { modelContext = modelContext, session, autoRefreshServer, frameworkConfig, pgListener } + let sessionVault = Vault.insert sessionVaultKey mempty Vault.empty + let applicationContext = ApplicationContext { modelContext = modelContext, autoRefreshServer, frameworkConfig, pgListener } let requestContext = RequestContext { request = defaultRequest {vault = sessionVault} , requestBody = FormBody [] [] , respond = const (pure ResponseReceived) - , vault = session , frameworkConfig = frameworkConfig } (hspecAction MockContext { .. }) @@ -81,17 +80,15 @@ mockContextNoDatabase application configBuilder = do logger <- newLogger def { level = Warn } -- don't log queries modelContext <- createModelContext dbPoolIdleTime dbPoolMaxConnections databaseUrl logger - session <- Vault.newKey - let sessionVault = Vault.insert session mempty Vault.empty + let sessionVault = Vault.insert sessionVaultKey mempty Vault.empty pgListener <- PGListener.init modelContext autoRefreshServer <- newIORef (AutoRefresh.newAutoRefreshServer pgListener) - let applicationContext = ApplicationContext { modelContext = modelContext, session, autoRefreshServer, frameworkConfig, pgListener } + let applicationContext = ApplicationContext { modelContext = modelContext, autoRefreshServer, frameworkConfig, pgListener } let requestContext = RequestContext { request = defaultRequest {vault = sessionVault} , requestBody = FormBody [] [] , respond = \resp -> pure ResponseReceived - , vault = session , frameworkConfig = frameworkConfig } pure MockContext{..} @@ -230,8 +227,8 @@ withUser user callback = insertSession key value = pure () - newVault = Vault.insert vaultKey newSession (Wai.vault request) - RequestContext { request, vault = vaultKey } = ?mocking.requestContext + newVault = Vault.insert sessionVaultKey newSession (Wai.vault request) + RequestContext { request } = ?mocking.requestContext sessionValue = Serialize.encode (user.id) sessionKey = cs (Session.sessionKey @user) diff --git a/Main.hs b/Main.hs index 0442bc236..0775466b4 100644 --- a/Main.hs +++ b/Main.hs @@ -13,12 +13,19 @@ data DemoController = DemoAction deriving (Eq, Show, Data) instance AutoRoute DemoController instance InitControllerContext RootApplication -instance FrontController RootApplication where +data WebApplication = WebApplication deriving (Eq, Show) + +instance InitControllerContext WebApplication where + initContext = pure () + +instance FrontController WebApplication where controllers = - [ parseRoute @DemoController - , startPage DemoAction + [ startPage DemoAction ] +instance FrontController RootApplication where + controllers = [ mountFrontController WebApplication ] + instance Controller DemoController where action DemoAction = renderPlain "Hello World!" diff --git a/NixSupport/default.nix b/NixSupport/default.nix index 5a0fa1975..50a8bc71d 100644 --- a/NixSupport/default.nix +++ b/NixSupport/default.nix @@ -1,4 +1,4 @@ -{ compiler ? "ghc94" +{ compiler ? "ghc96" , additionalNixpkgsOptions ? {} , pkgs ? import "${toString projectPath}/Config/nix/nixpkgs-config.nix" { ihp = ihp; additionalNixpkgsOptions = additionalNixpkgsOptions; } , ghc ? pkgs.haskell.packages.${compiler} diff --git a/NixSupport/haskell-packages/minio-hs.nix b/NixSupport/haskell-packages/minio-hs.nix new file mode 100644 index 000000000..d2322f623 --- /dev/null +++ b/NixSupport/haskell-packages/minio-hs.nix @@ -0,0 +1,41 @@ +{ mkDerivation, aeson, base, base64-bytestring, binary, bytestring +, case-insensitive, conduit, conduit-extra, crypton-connection +, cryptonite, cryptonite-conduit, digest, directory, filepath +, http-client, http-client-tls, http-conduit, http-types, ini, lib +, memory, network-uri, QuickCheck, raw-strings-qq, relude +, resourcet, retry, tasty, tasty-hunit, tasty-quickcheck +, tasty-smallcheck, text, time, time-units, transformers, unliftio +, unliftio-core, unordered-containers, xml-conduit, fetchFromGitHub +}: +mkDerivation { + pname = "minio-hs"; + version = "1.7.0"; + src = fetchFromGitHub { + owner = "mpscholten"; + repo = "minio-hs"; + rev = "786cf1881f0b62b7539e63547e76afc3c1ade36a"; + sha256 = "sha256-dK4nH6j16oFNB27tp1ExAWahhFUlKpzLpqTiHju8dl8="; + }; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson base base64-bytestring binary bytestring case-insensitive + conduit conduit-extra crypton-connection cryptonite + cryptonite-conduit digest directory filepath http-client + http-client-tls http-conduit http-types ini memory network-uri + relude resourcet retry text time time-units transformers unliftio + unliftio-core unordered-containers xml-conduit + ]; + testHaskellDepends = [ + aeson base base64-bytestring binary bytestring case-insensitive + conduit conduit-extra crypton-connection cryptonite + cryptonite-conduit digest directory filepath http-client + http-client-tls http-conduit http-types ini memory network-uri + QuickCheck raw-strings-qq relude resourcet retry tasty tasty-hunit + tasty-quickcheck tasty-smallcheck text time time-units transformers + unliftio unliftio-core unordered-containers xml-conduit + ]; + homepage = "https://github.com/minio/minio-hs#readme"; + description = "A MinIO Haskell Library for Amazon S3 compatible cloud storage"; + license = lib.licenses.asl20; +} \ No newline at end of file diff --git a/NixSupport/haskell-packages/smtp-mail.nix b/NixSupport/haskell-packages/smtp-mail.nix new file mode 100644 index 000000000..56c141158 --- /dev/null +++ b/NixSupport/haskell-packages/smtp-mail.nix @@ -0,0 +1,22 @@ +{ mkDerivation, array, base, base16-bytestring, base64-bytestring +, bytestring, crypton-connection, cryptonite, filepath, lib, memory +, mime-mail, network, network-bsd, text, fetchFromGitHub +}: +mkDerivation { + pname = "smtp-mail"; + version = "0.3.0.0"; + src = fetchFromGitHub { + owner = "MasterWordServices"; + repo = "smtp-mail"; + rev = "4c724c80814ab1da7c37256a6c10e04c88b9af95"; + sha256 = "sha256-gjg2k8UHBwumoH8OUsFZ+Xd5icotaVHRmlQt6Xc7Mf0="; + }; + libraryHaskellDepends = [ + array base base16-bytestring base64-bytestring bytestring + crypton-connection cryptonite filepath memory mime-mail network network-bsd + text + ]; + homepage = "http://github.com/jhickner/smtp-mail"; + description = "Simple email sending via SMTP"; + license = lib.licenses.bsd3; +} \ No newline at end of file diff --git a/NixSupport/make-nixpkgs-from-options.nix b/NixSupport/make-nixpkgs-from-options.nix index ff6551f27..5c3517906 100644 --- a/NixSupport/make-nixpkgs-from-options.nix +++ b/NixSupport/make-nixpkgs-from-options.nix @@ -8,7 +8,7 @@ , dontHaddockPackages ? [] , nixPkgsRev ? "277bf961c323b6cde46932cc9308135d0687af95" , nixPkgsSha256 ? "sha256-ciSZqliNLDattmjR/1yQ0mJHZSkXWZlnIR6TcdDGFMQ" -, compiler ? "ghc94" +, compiler ? "ghc96" , manualOverrides ? haskellPackagesNew: haskellPackagesOld: { } # More exotic overrides go here , additionalNixpkgsOptions ? {} }: diff --git a/Test/Controller/AccessDeniedSpec.hs b/Test/Controller/AccessDeniedSpec.hs index 8a847540c..dba55e502 100644 --- a/Test/Controller/AccessDeniedSpec.hs +++ b/Test/Controller/AccessDeniedSpec.hs @@ -74,8 +74,8 @@ config = do makeApplication :: (?applicationContext :: ApplicationContext) => IO Application makeApplication = do store <- Session.mapStore_ - let sessionMiddleware :: Middleware = Session.withSession store "SESSION" ?applicationContext.frameworkConfig.sessionCookie ?applicationContext.session - pure (sessionMiddleware (Server.application handleNotFound)) + let sessionMiddleware :: Middleware = Session.withSession store "SESSION" ?applicationContext.frameworkConfig.sessionCookie sessionVaultKey + pure (sessionMiddleware $ (Server.application handleNotFound) (\app -> app)) assertAccessDenied :: SResponse -> IO () assertAccessDenied response = do diff --git a/Test/Controller/CookieSpec.hs b/Test/Controller/CookieSpec.hs index ca950a556..8750b175e 100644 --- a/Test/Controller/CookieSpec.hs +++ b/Test/Controller/CookieSpec.hs @@ -37,6 +37,6 @@ createControllerContext = do let requestBody = FormBody { params = [], files = [] } request = Wai.defaultRequest - requestContext = RequestContext { request, respond = error "respond", requestBody, vault = error "vault", frameworkConfig = error "frameworkConfig" } + requestContext = RequestContext { request, respond = error "respond", requestBody, frameworkConfig = error "frameworkConfig" } let ?requestContext = requestContext newControllerContext diff --git a/Test/Controller/NotFoundSpec.hs b/Test/Controller/NotFoundSpec.hs index 2c526db25..b79ca3cd3 100644 --- a/Test/Controller/NotFoundSpec.hs +++ b/Test/Controller/NotFoundSpec.hs @@ -74,8 +74,8 @@ config = do makeApplication :: (?applicationContext :: ApplicationContext) => IO Application makeApplication = do store <- Session.mapStore_ - let sessionMiddleware :: Middleware = Session.withSession store "SESSION" ?applicationContext.frameworkConfig.sessionCookie ?applicationContext.session - pure (sessionMiddleware (Server.application handleNotFound)) + let sessionMiddleware :: Middleware = Session.withSession store "SESSION" ?applicationContext.frameworkConfig.sessionCookie sessionVaultKey + pure (sessionMiddleware $ (Server.application handleNotFound) (\app -> app)) assertNotFound :: SResponse -> IO () assertNotFound response = do diff --git a/Test/Controller/ParamSpec.hs b/Test/Controller/ParamSpec.hs index 630ad13c0..d45ed7c00 100644 --- a/Test/Controller/ParamSpec.hs +++ b/Test/Controller/ParamSpec.hs @@ -434,14 +434,14 @@ createControllerContextWithParams params = let requestBody = FormBody { params, files = [] } request = Wai.defaultRequest - requestContext = RequestContext { request, respond = error "respond", requestBody, vault = error "vault", frameworkConfig = error "frameworkConfig" } + requestContext = RequestContext { request, respond = error "respond", requestBody, frameworkConfig = error "frameworkConfig" } in FrozenControllerContext { requestContext, customFields = TypeMap.empty } createControllerContextWithJson params = let requestBody = JSONBody { jsonPayload = Just (json params), rawPayload = cs params } request = Wai.defaultRequest - requestContext = RequestContext { request, respond = error "respond", requestBody, vault = error "vault", frameworkConfig = error "frameworkConfig" } + requestContext = RequestContext { request, respond = error "respond", requestBody, frameworkConfig = error "frameworkConfig" } in FrozenControllerContext { requestContext, customFields = TypeMap.empty } json :: Text -> Aeson.Value diff --git a/Test/RouterSupportSpec.hs b/Test/RouterSupportSpec.hs index 09a4c0158..bac750f42 100644 --- a/Test/RouterSupportSpec.hs +++ b/Test/RouterSupportSpec.hs @@ -141,7 +141,7 @@ config = do option (AppPort 8000) application :: (?applicationContext :: ApplicationContext) => Application -application = Server.application handleNotFound +application = Server.application handleNotFound (\app -> app) tests :: Spec tests = beforeAll (mockContextNoDatabase WebApplication config) do diff --git a/Test/SEO/Sitemap.hs b/Test/SEO/Sitemap.hs index 82155b618..ed85c1d1b 100644 --- a/Test/SEO/Sitemap.hs +++ b/Test/SEO/Sitemap.hs @@ -79,5 +79,5 @@ tests = beforeAll (mockContextNoDatabase WebApplication config) do describe "SEO" do describe "Sitemap" do it "should render a XML Sitemap" $ withContext do - runSession (testGet "/sitemap.xml") (Server.application handleNotFound) + runSession (testGet "/sitemap.xml") (Server.application handleNotFound (\app -> app)) >>= assertSuccess "