diff --git a/src/data-layer/setup.md b/src/data-layer/setup.md index a0f2119..02a0027 100644 --- a/src/data-layer/setup.md +++ b/src/data-layer/setup.md @@ -50,12 +50,13 @@ This creates `src/blog_repo.erl` — a module that wraps all database operations preload/3, transaction/1, multi/1, query/2]). config() -> - #{pool => blog_repo, - database => <<"blog_dev">>, + Database = application:get_env(blog, database, <<"blog_dev">>), + #{pool => ?MODULE, + database => Database, hostname => <<"localhost">>, port => 5432, username => <<"postgres">>, - password => <<>>, + password => <<"postgres">>, pool_size => 10}. start() -> kura_repo_worker:start(?MODULE). @@ -76,7 +77,7 @@ multi(Multi) -> kura_repo_worker:multi(?MODULE, Multi). query(SQL, Params) -> kura_repo_worker:query(?MODULE, SQL, Params). ``` -Every function delegates to `kura_repo_worker` with the repo module as the first argument. The `config/0` callback tells Kura how to connect to PostgreSQL. +The `kura_repo` behaviour only requires one callback — `config/0` — which tells Kura how to connect to PostgreSQL. Every other function is a convenience delegation to `kura_repo_worker`. The setup command also creates `src/migrations/` for migration files. @@ -109,7 +110,9 @@ docker compose up -d ## Configuring the repo -Update `config/dev_sys.config.src` to include the repo config. The repo module reads its config from `config/0`, but you can also configure it through `sys.config` if you prefer environment variable substitution: +Notice that `config/0` uses `application:get_env(blog, database, <<"blog_dev">>)` for the database name. This means you can override it per environment through `sys.config` without touching the module. + +For example, to use a separate test database, add a `blog` section to your test sys.config: ```erlang [ @@ -138,10 +141,15 @@ Update `config/dev_sys.config.src` to include the repo config. The repo module r decode_json_body => true }} ]} + ]}, + {blog, [ + {database, <<"blog_test">>} ]} ]. ``` +The `blog_dev` default in `config/0` works for development without any sys.config entry. Override just the keys you need per environment. + ## Starting the repo in the supervisor The repo needs to be started when your application boots. Add it to your supervisor in `src/blog_sup.erl`: @@ -158,10 +166,15 @@ start_link() -> init([]) -> blog_repo:start(), + kura_migrator:migrate(blog_repo), {ok, {#{strategy => one_for_one, intensity => 5, period => 10}, []}}. ``` -`blog_repo:start()` creates the pgo connection pool using the config from `config/0`. +`blog_repo:start()` creates the pgo connection pool using the config from `config/0`. `kura_migrator:migrate/1` then runs any pending migrations — it tracks which versions have been applied in a `schema_migrations` table. + +```admonish tip +Auto-migrating on startup is convenient during development. For production, run migrations as a separate step before deploying (e.g. a release command or CI job) so that failures don't prevent the application from starting. +``` ## Adding the rebar3_kura compile hook @@ -169,11 +182,11 @@ To get automatic migration generation (covered in the next chapter), add a provi ```erlang {provider_hooks, [ - {post, [{compile, {kura, compile}}]} + {pre, [{compile, {kura, compile}}]} ]}. ``` -This runs `rebar3 kura compile` after every `rebar3 compile`, scanning your schemas and generating migrations for any changes. +This runs `rebar3 kura compile` before every `rebar3 compile`, scanning your schemas and generating migrations for any changes. ## Verifying the connection @@ -186,11 +199,11 @@ rebar3 nova serve You should see the application start without errors. If the database is unreachable, you will see a connection error in the logs. Verify from the shell: ```erlang -1> blog_repo:query("SELECT 1", []). -{ok, #{command => select, num_rows => 1, rows => [{1}]}} +1> blog_repo:query("SELECT 1 AS result", []). +{ok, [#{result => 1}]} ``` -Two commands and you have a database layer. +`query/2` returns `{ok, Rows}` where each row is a map with atom keys — the same format you will see from all Kura query functions. --- diff --git a/src/getting-started/views-auth-sessions.md b/src/getting-started/views-auth-sessions.md index 70a81e3..1b21f00 100644 --- a/src/getting-started/views-auth-sessions.md +++ b/src/getting-started/views-auth-sessions.md @@ -6,29 +6,80 @@ In this chapter we will build a login page with ErlyDTL templates, add authentic Nova uses [ErlyDTL](https://github.com/erlydtl/erlydtl) for HTML templating — an Erlang implementation of [Django's template language](https://django.readthedocs.io/en/1.6.x/ref/templates/builtins.html). Templates live in `src/views/` and are compiled to Erlang modules at build time. -### Creating a login template +### Template basics -Create `src/views/login.dtl`: +ErlyDTL supports the same syntax as Django templates: + +| Syntax | Purpose | Example | +|--------|---------|---------| +| `{{ var }}` | Output a variable | `{{ username }}` | +| `{% if cond %}...{% endif %}` | Conditional | `{% if error %}...{% endif %}` | +| `{% for x in list %}...{% endfor %}` | Loop | `{% for post in posts %}...{% endfor %}` | +| `{{ var\|filter }}` | Apply a filter | `{{ name\|upper }}` | +| `{{ var\|default:"n/a" }}` | Fallback value | `{{ bio\|default:"No bio" }}` | +| `{% extends "base.dtl" %}` | Inherit a layout | See below | +| `{% block name %}...{% endblock %}` | Override a block | See below | + +See the [ErlyDTL documentation](https://github.com/erlydtl/erlydtl) for the full list of tags and filters. + +### Creating a base layout + +Most pages share the same outer HTML. Template inheritance lets you define a base layout once and override specific blocks in child templates. + +Create `src/views/base.dtl`: ```html + + + + {% block title %}Blog{% endblock %} + -
- {% if error %}

{{ error }}

{% endif %} -
- -
- -
- -
-
+ +
+ {% block content %}{% endblock %} +
``` +Child templates use `{% extends "base.dtl" %}` and fill in the blocks they need. Anything outside a `{% block %}` tag in the child is ignored. + +### Creating a login template + +Create `src/views/login.dtl`: + +```html +{% extends "base.dtl" %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+ {% if error %}

{{ error }}

{% endif %} +
+ + +
+ +
+ +
+
+{% endblock %} +``` + This form POSTs to `/login` with `username` and `password` fields. The URL-encoded body will be decoded by `nova_request_plugin` (which we configured in the [Plugins](plugins.md) chapter). +The hidden `_csrf_token` field is required because we enabled `nova_csrf_plugin`. Nova automatically injects the `csrf_token` variable into every template — you just need to include it in the form. Without it, the POST request would be rejected with a 403 error. + ### Adding a controller function Our generated controller is in `src/controllers/blog_main_controller.erl`: @@ -58,13 +109,40 @@ When a controller returns `{ok, Variables}` (without a `view` option), Nova look When you specify `#{view => login}`, Nova uses `login.dtl` instead. +### Template options + +The full return tuple is `{ok, Variables, Options}` where `Options` is a map that supports three keys: + +| Option | Default | Description | +|--------|---------|-------------| +| `view` | derived from module name | Which template to render | +| `headers` | `#{<<"content-type">> => <<"text/html">>}` | Response headers | +| `status_code` | `200` | HTTP status code | + +Some examples: + +```erlang +%% Render login.dtl with default 200 status +{ok, [], #{view => login}}. + +%% Render with a 422 status (useful for form validation errors) +{ok, [{error, <<"Invalid input">>}], #{view => login, status_code => 422}}. + +%% Return plain text instead of HTML +{ok, [{data, Body}], #{headers => #{<<"content-type">> => <<"text/plain">>}}}. +``` + +```admonish tip +`{view, Variables}` and `{view, Variables, Options}` are aliases for `{ok, ...}` — they behave identically. +``` + ## Authentication -Now let's handle the login form submission with a security module. +Now let's protect routes so only logged-in users can access them. ### Security in route groups -Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or `false` (deny). +Authentication in Nova is configured per route group using the `security` key. It points to a function that receives the request and returns either `{true, AuthData}` (allow) or a denial value (deny). See ["How security works"](#how-security-works) below for all return values. ### Creating a security module @@ -72,34 +150,42 @@ Create `src/blog_auth.erl`: ```erlang -module(blog_auth). --export([ - username_password/1, - session_auth/1 - ]). - -%% Used for the login POST -username_password(#{params := Params}) -> - case Params of - #{<<"username">> := Username, - <<"password">> := <<"password">>} -> - {true, #{authed => true, username => Username}}; - _ -> - false - end. +-export([session_auth/1]). -%% Used for pages that need an active session session_auth(Req) -> case nova_session:get(Req, <<"username">>) of {ok, Username} -> - {true, #{authed => true, username => Username}}; + {true, #{username => Username}}; {error, _} -> - false + {redirect, "/login"} end. ``` -`username_password/1` checks the decoded form parameters. If the password matches, it returns `{true, AuthData}` — the auth data map is attached to the request and accessible in your controller as `auth_data`. +`session_auth/1` checks whether the session contains a username. If so, it returns `{true, AuthData}` — the auth data map is merged into the request and accessible in your controller as `auth_data`. If the session is empty, it redirects to the login page. -`session_auth/1` checks for an existing session (we will set this up next). +```admonish tip +Returning `{redirect, "/login"}` instead of bare `false` gives users a friendly redirect to the login page. A bare `false` would trigger the generic 401 error handler, which is more appropriate for APIs. We covered the 401 handler in the [Error Handling](../testing-errors/error-handling.md) chapter. +``` + +### Processing the login form + +Credential validation belongs in the controller, not the security function. The security function's job is to *gate access* — the login POST route is public by definition (unauthenticated users need to reach it), so it uses `security => false`. + +The controller checks the submitted credentials and either creates a session or re-renders the form with an error: + +```erlang +login_post(#{params := Params} = Req) -> + case Params of + #{<<"username">> := Username, + <<"password">> := <<"password">>} -> + nova_session:set(Req, <<"username">>, Username), + {redirect, "/"}; + _ -> + {ok, [{error, <<"Invalid username or password">>}], #{view => login}} + end. +``` + +On success, we store the username in the session and redirect to the home page. On failure, we re-render the login template with an error message — the user sees the form again instead of a raw error page. ```admonish warning This is a hardcoded password for demonstration only. In a real application you would validate credentials against a database with properly hashed passwords. @@ -113,9 +199,10 @@ The security flow for each request is: 2. If `security` is `false`, skip to the controller 3. If `security` is a function, call it with the request map 4. If it returns `{true, AuthData}`, merge `auth_data => AuthData` into the request and continue to the controller -5. If it returns `false`, trigger the 401 error handler -6. If it returns `{redirect, Path}`, redirect without calling the controller -7. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error +5. If it returns `true`, continue to the controller (no auth data attached) +6. If it returns `false`, trigger the 401 error handler +7. If it returns `{redirect, Path}`, send a 302 redirect without calling the controller +8. If it returns `{false, StatusCode, Headers, Body}`, respond with a custom error The structured `{false, StatusCode, Headers, Body}` form is useful for APIs where you want to return JSON error details instead of triggering the generic 401 handler. @@ -125,29 +212,45 @@ You can have different security functions for different route groups — one for Nova has a built-in session system backed by ETS (Erlang Term Storage). Session IDs are stored in a `session_id` cookie. +### How sessions work + +Nova automatically creates a session for every visitor. On each request, the `nova_stream_h` stream handler checks for a `session_id` cookie: + +- **Cookie exists** — the request proceeds normally. The session ID is read from the cookie when you call the session API. +- **No cookie** — Nova generates a new session ID, sets the `session_id` cookie on the response, and stores the ID in the request map. + +This means you never need to manually generate session IDs or set the session cookie. By the time your controller runs, every request already has a session — you just read from and write to it. + ### The session API ```erlang -nova_session:get(Req, <<"key">>) -> {ok, Value} | {error, not_found}. -nova_session:set(Req, <<"key">>, Value) -> ok. -nova_session:delete(Req) -> {ok, Req1}. -nova_session:delete(Req, <<"key">>) -> {ok, Req1}. -nova_session:generate_session_id() -> {ok, SessionId}. +nova_session:get(Req, Key) -> {ok, Value} | {error, not_found}. +nova_session:set(Req, Key, Value) -> ok | {error, session_id_not_set}. +nova_session:delete(Req) -> {ok, Req1}. +nova_session:delete(Req, Key) -> {ok, Req1}. ``` +| Function | Description | +|----------|-------------| +| `get/2` | Retrieve a value by key. Returns `{error, not_found}` if the key or session doesn't exist. | +| `set/3` | Store a value in the current session. | +| `delete/1` | Delete the entire session and expire the cookie (sets `max_age => 0`). Returns an updated request — use this `Req1` if you need the cookie change in the response. | +| `delete/2` | Delete a single key from the session. | + The session manager is configured in `sys.config`: ```erlang {nova, [ - {session_manager, nova_session_ets} + {use_sessions, true}, %% Enable sessions (default: true) + {session_manager, nova_session_ets} %% Backend module (default) ]} ``` -`nova_session_ets` is the default. It stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`. +`nova_session_ets` stores session data in an ETS table and replicates changes across clustered nodes using `nova_pubsub`. Set `use_sessions` to `false` if your application doesn't need sessions (e.g. a pure JSON API). ### Wiring up the login flow -Update the controller to create a session on successful login: +Update the controller to handle login, logout, and the home page: ```erlang -module(blog_main_controller). @@ -158,58 +261,59 @@ Update the controller to create a session on successful login: logout/1 ]). -index(#{auth_data := #{authed := true, username := Username}}) -> - {ok, [{message, <<"Hello ", Username/binary>>}]}; -index(_Req) -> - {redirect, "/login"}. +index(#{auth_data := #{username := Username}}) -> + {ok, [{message, <<"Hello ", Username/binary>>}]}. login(_Req) -> {ok, [], #{view => login}}. -login_post(#{auth_data := #{authed := true, username := Username}} = Req) -> - {ok, SessionId} = nova_session:generate_session_id(), - Req1 = cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, - #{path => <<"/">>, http_only => true}), - Req2 = Req1#{nova_session_id => SessionId}, - nova_session:set(Req2, <<"username">>, Username), - {redirect, "/", Req1}; -login_post(_Req) -> - {ok, [{error, <<"Invalid username or password">>}], #{view => login}}. +login_post(#{params := Params} = Req) -> + case Params of + #{<<"username">> := Username, + <<"password">> := <<"password">>} -> + nova_session:set(Req, <<"username">>, Username), + {redirect, "/"}; + _ -> + {ok, [{error, <<"Invalid username or password">>}], #{view => login}} + end. logout(Req) -> - {ok, _Req1} = nova_session:delete(Req), - {redirect, "/login"}. + {ok, Req1} = nova_session:delete(Req), + {redirect, "/login", Req1}. ``` The login flow: -1. Generate a session ID -2. Set the `session_id` cookie on the response -3. Store the username in the session -4. Redirect to the home page +1. User visits `/login` — sees the login form +2. Form POSTs to `/login` — `login_post/1` checks credentials +3. On success, store the username in the session and redirect to `/` +4. On failure, re-render the form with an error message +5. On `/`, `session_auth/1` verifies the session and populates `auth_data` +6. `/logout` deletes the session, expires the cookie, and redirects to `/login` + +Notice that `index/1` only has one clause — it pattern-matches on `auth_data` directly. Since the route group uses `session_auth/1`, unauthenticated users are redirected before the controller runs. + +The `logout/1` function passes `Req1` (from `nova_session:delete/1`) as the third element of the redirect tuple. This ensures the expired cookie is included in the response. + +```admonish tip +Nova auto-creates the session cookie, so `login_post/1` just calls `nova_session:set/3` — no manual session ID generation or cookie setting needed. +``` ### Updating the routes ```erlang routes(_Environment) -> [ - %% Public routes + %% Public routes (no auth required) #{prefix => "", security => false, routes => [ {"/login", fun blog_main_controller:login/1, #{methods => [get]}}, + {"/login", fun blog_main_controller:login_post/1, #{methods => [post]}}, {"/heartbeat", fun(_) -> {status, 200} end, #{methods => [get]}} ] }, - %% Login POST (uses username/password auth) - #{prefix => "", - security => fun blog_auth:username_password/1, - routes => [ - {"/login", fun blog_main_controller:login_post/1, #{methods => [post]}} - ] - }, - - %% Protected pages (uses session auth) + %% Protected routes (session auth required) #{prefix => "", security => fun blog_auth:session_auth/1, routes => [ @@ -220,16 +324,20 @@ routes(_Environment) -> ]. ``` +Two route groups instead of three: +1. **Public** — login (GET and POST) and heartbeat. `security => false` means no auth check. Credential validation happens inside `login_post/1`. +2. **Protected** — home page and logout. `session_auth/1` redirects unauthenticated users to `/login`. + Now the flow is: 1. User visits `/login` — sees the login form -2. Form POSTs to `/login` — `username_password/1` checks credentials -3. On success, a session is created and the user is redirected to `/` -4. On `/`, `session_auth/1` checks the session cookie +2. Form POSTs to `/login` — controller checks credentials +3. On success, a session value is set and the user is redirected to `/` +4. On `/`, `session_auth/1` checks the session 5. `/logout` deletes the session and redirects to `/login` ### Cookie options -When setting the session cookie, control its behaviour with options: +Nova sets the `session_id` cookie automatically with default options. For production, you may want to customise the cookie by setting it yourself in a [plugin](plugins.md) or by configuring Cowboy's cookie defaults: ```erlang cowboy_req:set_resp_cookie(<<"session_id">>, SessionId, Req, #{