Compare commits

...

46 Commits

Author SHA1 Message Date
Willem van den Ende
b2a4cdab42 Add scheduled publishing release note blog post
Includes 7 screenshots demonstrating date filtering, status
banners, editor dashboard, and registration gating.
2026-04-01 22:10:45 +00:00
Willem van den Ende
b6ff541b13 Fix status banner auth check to use current_scope
phx.gen.auth sets current_scope, not current_user. Use !! to
ensure boolean for HEEx template and register_and_log_in_user
in tests for proper auth session.
2026-04-01 22:06:07 +00:00
Willem van den Ende
88ec475a5b Sync beads: close all 12 scheduled publishing issues 2026-04-01 21:48:38 +00:00
Willem van den Ende
74a1201b88 Add integration tests for scheduled post filtering in Phoenix 2026-04-01 21:47:15 +00:00
Willem van den Ende
8d40e09e90 Verify router respects date filtering on all endpoints 2026-04-01 21:46:59 +00:00
Willem van den Ende
2591796d82 Format editor dashboard LiveView 2026-04-01 21:44:12 +00:00
Willem van den Ende
f1560ff0e7 Add LiveView editor dashboard with drafts and scheduled tabs 2026-04-01 21:42:45 +00:00
Willem van den Ende
5395b2de80 Show draft/scheduled status banners for authenticated users 2026-04-01 21:40:17 +00:00
Willem van den Ende
86f7ffbe94 Gate registration to ALLOWED_REGISTRATION_EMAIL 2026-04-01 21:39:15 +00:00
Willem van den Ende
df20c478f4 Seed demo user in dev environment 2026-04-01 21:36:58 +00:00
Willem van den Ende
20f12847d6 Allow direct access to draft and scheduled posts by slug 2026-04-01 20:39:19 +00:00
Willem van den Ende
370275f7b5 Verify feeds exclude future-dated posts 2026-04-01 20:37:27 +00:00
Willem van den Ende
a380d0cb69 Add phx.gen.auth authentication scaffolding
- LiveView-based email/password auth via mix phx.gen.auth
- Auth links removed from public navigation (direct URL access only)
- Accounts context with User schema and token management
2026-04-01 20:31:13 +00:00
Willem van den Ende
0577ceced0 Filter future-dated posts from public views and add unfiltered post access
- all_posts/0 now excludes posts where date > today
- all_tags/0 computed at runtime from filtered posts
- posts_by_tag/1 and recent_posts/1 inherit date filtering
- Add unfiltered_posts/0 to Blog macro and FakeBlog
- Add all_posts_unfiltered/0 to Registry for dashboard use
2026-04-01 20:30:27 +00:00
Willem van den Ende
037e9f86ff Add post visibility and days_until_live helpers 2026-04-01 20:24:33 +00:00
Firehose Bot
17a0f2709c Add current_tag attribute to post_index component 2026-03-24 14:56:54 +00:00
Firehose Bot
cf7df3111f Linting rule only for dev and test
Production build broke because our custom lint rule was compiled. The
credo linter is not available and not necessary in production.

Solution: create separate directory for dev tools.
2026-03-24 14:41:58 +00:00
Firehose Bot
04a736765d not running autoresearch at the moment 2026-03-24 14:23:00 +00:00
Firehose Bot
590dd4a265 Fix trailing newline and format code 2026-03-24 14:21:22 +00:00
Firehose Bot
fddbb4e777 remove sequence diagram skill, moved to other repo 2026-03-24 12:14:01 +00:00
Firehose Bot
b3cdd93de8 nono sandbox 2026-03-24 12:13:05 +00:00
Firehose Bot
87e6490f85 post: blog triage with an llm 2026-03-24 12:11:23 +00:00
Firehose Bot
afc763d9d9 fix score according to claude desktop 2026-03-21 18:41:21 +00:00
Firehose Bot
2708f81f1d initial setup for autoresearch of sequence diagram prompt 2026-03-21 15:39:15 +00:00
Firehose Bot
73e0d9cf1e sandboxed haiku with pi 2026-03-21 15:36:51 +00:00
Willem van den Ende
505a2d0bd6 Add custom Credo check for conn shadowing in tests
Detects `conn = get(conn, ...)` patterns and directs to
refactor_conn_aliasing.sh for automatic fixing.
2026-03-20 21:37:04 +00:00
Willem van den Ende
9426582abc Refactor conn aliasing in controller tests to use pipe chains
Applied refactor_conn_aliasing.sh to eliminate conn shadowing.

Show draft posts in test and dev
2026-03-20 21:36:08 +00:00
Willem van den Ende
c18f9cd2e3 Fix Sandbox module not available in DataCase setup
The alias was inside the `using` block (only available to consumers),
but setup_sandbox/1 runs in DataCase itself. Use fully qualified name.
2026-03-20 21:19:54 +00:00
Willem van den Ende
5d49af2790 Add reusable script to refactor Phoenix test conn aliasing
Portable awk-based script that transforms conn shadowing patterns
into idiomatic pipe chains across 4 cases (body extraction, single
assert, pattern match assert, multi-use rename).
2026-03-20 21:19:54 +00:00
Firehose Bot
671add15bb fix blog tag clicks, and new post 2026-03-19 22:14:19 +00:00
Firehose Bot
be4be118a3 Fix review issues from commit 2a21d75
1. Rename goto_engineering_post_page/2 to visit_engineering_path/2 for
   better accuracy (used for both post pages and tag pages)

2. Simplify Makefile test target by removing explicit ecto.create and
   ecto.migrate commands (mix test handles migrations automatically)

3. Update blog_test.exs header comment to reflect actual changes made

4. Move Sandbox alias to top level in data_case.ex
2026-03-19 11:07:17 +00:00
Firehose Bot
afdf557174 adjust makefile and refactor test 2026-03-18 20:04:41 +00:00
Firehose Bot
506c72b2d8 also write something about unit tests 2026-03-18 20:03:41 +00:00
Firehose Bot
2d94bbde62 test writer skill
Focuses on integration tests, but might be more reusable
2026-03-18 20:02:20 +00:00
Firehose Bot
c9901691e5 Fix blog API tests and add missing tag tests
- Add Accept: application/json headers to all API endpoint tests
- Add GET /blog/releases/tag/:tag HTML page test
- Add GET /api/blog/*/tag/:tag JSON API tests for both blogs
- Fix feed.xml assertions to check body first, then content type
2026-03-18 19:03:40 +00:00
Willem van den Ende
a5c26f24ab Add healthcheck and attempt to fix devcontainer
user was root instead of vscode, and pi was broken.
Claude code had gone missing
2026-03-18 17:15:45 +00:00
3837a72059 update blog post, and run credo with 'pi' 2026-03-18 15:03:24 +00:00
9be7c1774b Dokku setup script did not work that well, fixed by hand 2026-03-18 14:38:45 +00:00
Willem van den Ende
a5dee5c21e set default port to 5000 for production 2026-03-18 13:55:49 +00:00
Willem van den Ende
9e6252e1e7 set DATABASE_URL 2026-03-18 13:48:12 +00:00
Willem van den Ende
6f2beb8bb8 Add MIT license 2026-03-18 13:22:12 +00:00
Willem van den Ende
24847ca7fd Clearly mark sample posts as generated 2026-03-18 12:11:28 +00:00
Willem van den Ende
e0e5acb322 open port 4050 for testing in docker compose file 2026-03-18 12:07:20 +00:00
Willem van den Ende
9bad5d3770 Enable UTF-8 in devcontainer 2026-03-18 11:30:27 +00:00
Willem van den Ende
f563d3c26a Add postgres to devcontainer / compose 2026-03-18 11:22:54 +00:00
Willem van den Ende
e780d6b6e5 Add Dockerfile-based Dokku deployment for monorepo layout
Uses a multi-stage Docker build that copies both app/ and blogex/,
preserving the path dependency. Includes release scripts, migration
module, and a sample Dokku setup script.
2026-03-18 10:55:44 +00:00
112 changed files with 5075 additions and 265 deletions

44
.beads/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json
last-touched
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Legacy database files
db.sqlite
bd.db
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# Sync state (local-only, per-machine)
# These files are machine-specific and should not be shared across clones
.sync.lock
sync_base.jsonl
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
# They would override fork protection in .git/info/exclude, allowing
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.

81
.beads/README.md Normal file
View File

@ -0,0 +1,81 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --status in_progress
bd update <issue-id> --status done
# Sync with git remote
bd sync
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
✨ **AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Intelligent JSONL merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought* ⚡

62
.beads/config.yaml Normal file
View File

@ -0,0 +1,62 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, no SQLite, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of SQLite database
# no-db: false
# Disable daemon for RPC communication (forces direct database access)
# no-daemon: false
# Disable auto-flush of database to JSONL after mutations
# no-auto-flush: false
# Disable auto-import from JSONL when it's newer than database
# no-auto-import: false
# Enable JSON output by default
# json: false
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Path to database (overridden by BEADS_DB or --db)
# db: ""
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
# auto-start-daemon: true
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
# flush-debounce: "5s"
# Git branch for beads commits (bd sync will commit to this branch)
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
# This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
# sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo

View File

12
.beads/issues.jsonl Normal file
View File

@ -0,0 +1,12 @@
{"id":"firehose-1h8","title":"Verify feeds exclude future-dated posts","description":"## Context\nRSS/Atom feeds call blog.all_posts() which should now filter by date (from Step 1).\nAdd explicit tests confirming feeds exclude future-dated published posts.\n\n## Scope\n- blogex/test/blogex/feed_test.exs\n\n## TDD\nRED: Test RSS and Atom feeds exclude future-dated published posts\nGREEN: Should already pass from Step 1 changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.213785081Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:38:37.480901856Z","closed_at":"2026-04-01T20:38:37.480901856Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1h8","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.701493058Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-1x3","title":"Make get_post/get_post! search all compiled posts (unfiltered)","description":"## Context\nget_post/1 and get_post!/1 currently search all_posts() (filtered). Change to search @posts (unfiltered)\nso direct URL access works for draft and scheduled posts. Enables preview links for reviewers.\n\n## Scope\n- blogex/lib/blogex/blog.ex: get_post/1, get_post!/1\n- blogex/test/support/fake_blog.ex: get_post/1, get_post!/1\n- blogex/test/blogex/blog_test.exs: update existing tests, add new ones\n\n## TDD\nRED: Test get_post! returns future-dated post, get_post returns draft post\nGREEN: Search @posts instead of all_posts()\nREFACTOR: Update existing test that expects get_post!(\"draft-post\") to raise","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:04.676875214Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:39:26.605057721Z","closed_at":"2026-04-01T20:39:26.605057721Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-1x3","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.666577397Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-2wc","title":"Add date filtering to Blogex all_posts/0","description":"## Context\nall_posts() in blogex/lib/blogex/blog.ex (line 77-83) currently filters by `published` boolean only.\nAdd `date \u003c= Date.utc_today()` filter so future-dated posts are hidden from public views.\n\n## Scope\n- blogex/lib/blogex/blog.ex: all_posts/0\n- blogex/test/support/fake_blog.ex: all_posts/0\n- blogex/test/blogex/blog_test.exs: new tests\n- blogex/test/support/setup.ex: add future-dated post to default_posts\n\n## TDD\nRED: Test that future-dated published post is excluded from all_posts, posts_by_tag, recent_posts, all_tags\nGREEN: Add date filter after published filter\nREFACTOR: Extract filtering predicate if duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:06:54.303723951Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.372076738Z","closed_at":"2026-04-01T20:31:20.372076738Z","close_reason":"Closed"}
{"id":"firehose-4nq","title":"Add post visibility and days_until_live helpers","description":"## Context\nDashboard and status banners need to compute post visibility (draft/scheduled/live)\nand days until a scheduled post goes live.\n\n## Scope\n- blogex/lib/blogex/post.ex: add visibility/1 and days_until_live/1\n- blogex/test/blogex/post_test.exs: new tests\n\n## TDD\nRED: Test visibility returns :draft/:scheduled/:live correctly, days_until_live returns integer or nil\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.5973142Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:24:39.851993851Z","closed_at":"2026-04-01T20:24:39.851993851Z","close_reason":"Closed"}
{"id":"firehose-4yh","title":"Create LiveView editor dashboard","description":"## Context\nLiveView at /editor/dashboard behind auth. Two tabs: drafts and scheduled.\nUnified timeline across all blogs. Scheduled posts show \"X days until live\".\nLinks to post show page.\n\n## Scope\n- app/lib/firehose_web/live/editor_dashboard_live.ex\n- app/lib/firehose_web/router.ex: add /editor scope\n- app/test/firehose_web/live/editor_dashboard_live_test.exs\n\n## TDD\nRED: Unauth redirected, auth sees dashboard, drafts tab, scheduled tab with countdown, links work\nGREEN: Implement LiveView, add route\nREFACTOR: Extract tab component if markup duplicated","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.673871753Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:42:49.026878069Z","closed_at":"2026-04-01T21:42:49.026878069Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-4yh","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.570736282Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-ai8","type":"blocks","created_at":"2026-04-01T20:08:01.597663464Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-4yh","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.625180883Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-8zg","title":"Gate registration to ALLOWED_REGISTRATION_EMAIL","description":"## Context\nRegistration must be restricted to a single email from env var.\nUnset = disabled. Wrong email = \"registration is invite only.\"\n\n## Scope\n- app/config/runtime.exs: read ALLOWED_REGISTRATION_EMAIL\n- app/config/test.exs: set test value\n- Registration controller or Accounts context: add validation\n- Registration tests: add gating tests\n\n## TDD\nRED: Registration succeeds for matching email, fails for non-matching, fails when unset\nGREEN: Add config reading + validation check\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.051938506Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:39:21.420987916Z","closed_at":"2026-04-01T21:39:21.420987916Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-8zg","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.502562336Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-ai8","title":"Add unfiltered post access for dashboard","description":"## Context\nDashboard needs access to all posts including drafts and future-dated.\nAdd unfiltered_posts/0 to Blog macro and all_posts_unfiltered/0 to Registry.\n\n## Scope\n- blogex/lib/blogex/blog.ex: add unfiltered_posts/0\n- blogex/lib/blogex/registry.ex: add all_posts_unfiltered/0\n- blogex/test/support/fake_blog.ex: add unfiltered_posts/0\n- blogex/test/blogex/registry_test.exs: new tests\n\n## TDD\nRED: Test unfiltered returns all posts including drafts and future-dated\nGREEN: Implement functions\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.63593107Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37549839Z","closed_at":"2026-04-01T20:31:20.37549839Z","close_reason":"Closed"}
{"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.881106002Z","closed_at":"2026-04-01T21:47:19.881106002Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"}
{"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]}
{"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:47:19.87799142Z","closed_at":"2026-04-01T21:47:19.87799142Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]}

4
.beads/metadata.json Normal file
View File

@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

View File

@ -1,6 +1,11 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04
USER root USER root
RUN apt-get update && apt-get install -y locales && \
sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \
locale-gen en_US.UTF-8
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs apt-get install -y nodejs
@ -19,4 +24,7 @@ RUN /home/vscode/.local/bin/mise exec -- mix local.hex --force && \
RUN npm install -g @mariozechner/pi-coding-agent RUN npm install -g @mariozechner/pi-coding-agent
USER root RUN npm install -g @anthropic-ai/claude-code
RUN echo 'eval "$(/home/vscode/.local/bin/mise activate bash)"' >> /home/vscode/.bashrc
RUN /home/vscode/.local/bin/mise settings set trusted_config_paths /workspaces/firehose

View File

@ -1,10 +1,12 @@
{ {
"$schema": "https://containers.dev/implementors/json_schema/", "$schema": "https://containers.dev/implementors/json_schema/",
"build": { "dockerComposeFile": "docker-compose.yml",
"dockerfile": "Dockerfile" "service": "app",
}, "workspaceFolder": "/workspaces/firehose",
"remoteUser": "vscode", "remoteUser": "vscode",
"runArgs": [], "containerEnv": {
"DB_HOST": "db"
},
"features": { "features": {
"ghcr.io/devcontainers/features/python:1": {}, "ghcr.io/devcontainers/features/python:1": {},
"ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {},
@ -17,7 +19,7 @@
"source=${localEnv:HOME}/.pi/agent/bin,target=/home/vscode/.pi/agent/bin,type=bind,consistency=cached" "source=${localEnv:HOME}/.pi/agent/bin,target=/home/vscode/.pi/agent/bin,type=bind,consistency=cached"
], ],
"postCreateCommand": { "postCreateCommand": {
"pi-subagents": "bash -ic 'pi install npm:pi-subagents'" "pi-subagents-disabled": "echo 'pi-subagents disabled: upstream JSON schema bug — investigate version pinning separately'"
}, },
"customizations": { "customizations": {
"jetbrains": { "jetbrains": {

View File

@ -0,0 +1,30 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspaces/firehose:cached
ports:
- "4050:4050"
command: sleep infinity
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:

31
.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# Git
.git
# Build artifacts
app/_build
app/deps
blogex/_build
blogex/deps
# Dev/test only
app/test
blogex/test
app/.formatter.exs
blogex/.formatter.exs
# IDE
.devcontainer
.claude
# Documentation
*.md
!app/README.md
# Misc
app/tmp
app/cover
app/doc
blogex/doc
# Dokku setup (may contain secrets)
dokku-setup.sh

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Dokku setup (may contain secrets)
dokku-setup.sh
/output/

BIN
.nono.sh.swp Normal file

Binary file not shown.

View File

@ -0,0 +1,148 @@
---
name: test-writer
description: Writes tests following Elixir/Phoenix best practices. Ensures DRY tests with proper helper functions, no duplicated setup code, and correct parameter defaults. Use when writing or modifying tests.
---
# Test Writer Skill
## Overview
This skill provides guidelines for writing clean, maintainable Elixir/Phoenix tests following best practices. It focuses mainly on integration tests. For unit tests, we also want glanceable tests with unique names for values, test helpers, custom matchers and shared setups where appropriate.
## Core Principles
### 1. DRY Tests
Avoid duplication by creating focused helper functions:
**Bad:**
```elixir
test "GET /users returns index", %{conn: conn} do
conn = get(conn, "/users")
body = html_response(conn, 200)
assert body =~ "Users"
end
test "GET /users/:id returns show", %{conn: conn} do
conn = get(conn, "/users/1")
body = html_response(conn, 200)
assert body =~ "User"
end
```
**Good:**
```elixir
defp goto_users_page(conn, suffix \\ ""), do: get(conn, "/users" <> suffix)
test "GET /users returns index", %{conn: conn} do
conn = goto_users_page(conn)
assert html_response(conn, 200) =~ "Users"
end
test "GET /users/:id returns show", %{conn: conn} do
conn = goto_users_page(conn, "/1")
assert html_response(conn, 200) =~ "User"
end
```
### 2. Separate Helpers for Different Assertion Patterns
Don't use conditionals in helpers to handle different cases:
**Bad:**
```elixir
defp goto_users_page(conn, suffix \\ "", check_title \\ true) do
path = "/users" <> suffix
conn = get(conn, path)
body = html_response(conn, 200)
if check_title, do: assert body =~ "Users"
assert body =~ "AppLayout"
body
end
```
**Good:**
```elixir
defp goto_users_page(conn, suffix \\ "") do
path = "/users" <> suffix
conn = get(conn, path)
body = html_response(conn, 200)
assert body =~ "Users"
assert body =~ "AppLayout"
body
end
defp goto_user_page(conn, suffix) do
path = "/users" <> suffix
conn = get(conn, path)
body = html_response(conn, 200)
assert body =~ "AppLayout"
body
end
```
## Test Structure
### Context Block
```elixir
describe "resource name" do
# Shared setup in context if needed
# test "scenario" do ...
end
```
### Value Aliasing
Never reuse value names. Elixir is immutable, but value aliasing is confusing. Use unique, meaningful names for the left hand side of assignments. Or use pipes `|>` to eliminate the need for naming.
```elixir
test "GET /users returns index", %{conn: conn} do
# don't reassign
response = get(conn, "/users")
# ...
end
```
## Common Patterns
### HTML Pages with Layout
```elixir
defp goto_resource_page(conn, suffix \\ ""), do: ...
# Asserts common layout elements and page-specific content
```
### API Endpoints
```elixir
test "GET /api/resource returns JSON", %{conn: conn} do
conn = conn |> put_req_header("accept", "application/json")
conn = get(conn, "/api/resource")
response = json_response(conn, 200)
# Assert structure
end
```
### Error Handling
```elixir
test "returns 404 for nonexistent", %{conn: conn} do
assert html_response(get(conn, "/nonexistent"), 404)
end
```
## Running Tests
### One focused test file
```bash
cd /path/to/app
mix test test/path/to_test.exs
```
### all tests
```bash
make test
```

86
Dockerfile Normal file
View File

@ -0,0 +1,86 @@
# Dockerfile for Dokku deployment
# Multi-stage build for Phoenix/Elixir app with monorepo layout
ARG ELIXIR_VERSION=1.18.3
ARG OTP_VERSION=27.2.4
ARG DEBIAN_VERSION=bookworm-20260316-slim
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
# =============================================================================
# Build stage
# =============================================================================
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /build
# Install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
ENV MIX_ENV="prod"
# Copy blogex dependency first (changes less often)
COPY blogex /build/blogex
# Copy app dependency files first for better layer caching
COPY app/mix.exs app/mix.lock /build/app/
WORKDIR /build/app
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# Copy compile-time config files
COPY app/config/config.exs app/config/${MIX_ENV}.exs config/
RUN mix deps.compile
# Copy application source and compile
COPY app/priv priv
COPY app/assets assets
COPY app/lib lib
COPY app/rel rel
COPY app/config/runtime.exs config/
RUN mix compile
# Build assets after compile (phoenix-colocated hooks need compiled app)
RUN mix assets.deploy
# Build the release
RUN mix release
# =============================================================================
# Runtime stage
# =============================================================================
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
WORKDIR /app
RUN chown nobody /app
ENV MIX_ENV="prod"
# Copy the release from the build stage
COPY --from=builder --chown=nobody:root /build/app/_build/${MIX_ENV}/rel/firehose ./
USER nobody
# Dokku uses the EXPOSE port for routing
EXPOSE 5000
ENV PHX_SERVER=true
CMD ["/app/bin/server"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Living Software LTD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
# Makefile for Firehose monorepo
.PHONY: check precommit deps compile test format
# Common check target that runs all static analysis
check:
@echo "Running static analysis..."
@make -C app MISE_BIN=mise check
# Precommit target for CI/pre-commit hooks
precommit: check
# Sync dependencies
deps:
@make -C app deps
# Compile the project
compile:
@make -C app compile
# Run tests
test:
@make -C app test
# Format code
format:
@make -C app format

20
app.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "firehose",
"healthchecks": {
"web": [
{
"type": "startup",
"name": "web check",
"path": "/",
"attempts": 5,
"wait": 3,
"timeout": 5
}
]
},
"scripts": {
"dokku": {
"postdeploy": "/app/bin/migrate"
}
}
}

226
app/.credo.exs Normal file
View File

@ -0,0 +1,226 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: ["lib_dev/firehose/checks/"],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.StructFieldAmount, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedMapOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFilename, []},
#
## Custom Checks
#
{Firehose.Checks.NoConnShadowing, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.CondInsteadOfIfElse, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

View File

@ -44,6 +44,37 @@ custom classes must fully style the input
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions - Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- phoenix-gen-auth-start -->
## Authentication
- **Always** handle authentication flow at the router level with proper redirects
- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs:
- A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
- A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
- In both cases, a `@current_scope` is assigned to the Plug connection
- A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY**
- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates
- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below**
### Routes that require authentication
Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", MyControllerThatRequiresAuth, :index
end
### Routes that work with or without authentication
Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
<!-- phoenix-gen-auth-end -->
<!-- usage-rules-start --> <!-- usage-rules-start -->
<!-- phoenix:elixir-start --> <!-- phoenix:elixir-start -->

33
app/Makefile Normal file
View File

@ -0,0 +1,33 @@
# Makefile for Firehose app
MISE_BIN ?= /home/vscode/.local/bin/mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: check precommit deps compile test format credo
# Run all static analysis checks (no database required)
check: credo format
# Precommit target for CI/pre-commit hooks
precommit: check compile
# Sync dependencies
deps:
$(MISE_EXEC) mix deps.get
# Compile the project
compile:
$(MISE_EXEC) mix compile --warnings-as-errors
# Run tests (requires PostgreSQL running on localhost:5432)
# Note: If you don't have PostgreSQL, you can skip tests with `make check`
test: deps compile
$(MISE_EXEC) mix test
# Format code
format:
$(MISE_EXEC) mix format
# Run Credo static analysis
credo:
$(MISE_EXEC) mix credo --strict

View File

@ -7,6 +7,19 @@
# General application configuration # General application configuration
import Config import Config
config :firehose, :scopes,
user: [
default: true,
module: Firehose.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Firehose.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
config :firehose, config :firehose,
ecto_repos: [Firehose.Repo], ecto_repos: [Firehose.Repo],
generators: [timestamp_type: :utc_datetime] generators: [timestamp_type: :utc_datetime]
@ -61,7 +74,8 @@ config :logger, :default_formatter,
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason
config :blogex, config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes] blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes],
show_drafts: true
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.

View File

@ -4,7 +4,7 @@ import Config
config :firehose, Firehose.Repo, config :firehose, Firehose.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
hostname: "localhost", hostname: System.get_env("DB_HOST") || "localhost",
database: "firehose_dev", database: "firehose_dev",
stacktrace: true, stacktrace: true,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,

View File

@ -13,6 +13,9 @@ config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage # Disable Swoosh Local Memory Storage
config :swoosh, local: false config :swoosh, local: false
# Hide draft blog posts in production
config :blogex, show_drafts: false
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: :info config :logger, level: :info

View File

@ -20,6 +20,8 @@ if System.get_env("PHX_SERVER") do
config :firehose, FirehoseWeb.Endpoint, server: true config :firehose, FirehoseWeb.Endpoint, server: true
end end
config :firehose, :allowed_registration_email, System.get_env("ALLOWED_REGISTRATION_EMAIL")
if config_env() == :prod do if config_env() == :prod do
database_url = database_url =
System.get_env("DATABASE_URL") || System.get_env("DATABASE_URL") ||
@ -51,7 +53,7 @@ if config_env() == :prod do
""" """
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "5000")
config :firehose, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :firehose, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")

View File

@ -1,5 +1,8 @@
import Config import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
# Configure your database # Configure your database
# #
# The MIX_TEST_PARTITION environment variable can be used # The MIX_TEST_PARTITION environment variable can be used
@ -8,7 +11,7 @@ import Config
config :firehose, Firehose.Repo, config :firehose, Firehose.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
hostname: "localhost", hostname: System.get_env("DB_HOST") || "localhost",
database: "firehose_test#{System.get_env("MIX_TEST_PARTITION")}", database: "firehose_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 pool_size: System.schedulers_online() * 2
@ -35,3 +38,5 @@ config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks # Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view, config :phoenix_live_view,
enable_expensive_runtime_checks: true enable_expensive_runtime_checks: true
config :firehose, :allowed_registration_email, nil

View File

@ -0,0 +1,297 @@
defmodule Firehose.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Firehose.Repo
alias Firehose.Accounts.{User, UserToken, UserNotifier}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_user(attrs) do
%User{}
|> User.email_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `Firehose.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `Firehose.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the user with the given magic link token.
"""
def get_user_by_magic_link_token(token) do
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
{user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Logs the user in by magic link.
There are three cases to consider:
1. The user has already confirmed their email. They are logged in
and the magic link is expired.
2. The user has not confirmed their email and no password is set.
In this case, the user gets confirmed, logged in, and all tokens -
including session ones - are expired. In theory, no other tokens
exist but we delete all of them for best security practices.
3. The user has not confirmed their email but a password is set.
This cannot happen in the default implementation but may be the
source of security pitfalls. See the "Mixing magic link and password registration" section of
`mix help phx.gen.auth`.
"""
def login_user_by_magic_link(token) do
{:ok, query} = UserToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
raise """
magic link log in is not allowed for unconfirmed users with a password set!
This cannot happen with the default implementation, which indicates that you
might have adapted the code to a different use case. Please make sure to read the
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
"""
{%User{confirmed_at: nil} = user, _token} ->
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
{user, token} ->
Repo.delete!(token)
{:ok, {user, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given user.
"""
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
Repo.insert!(user_token)
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end

View File

@ -0,0 +1,33 @@
defmodule Firehose.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `Firehose.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias Firehose.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
end

View File

@ -0,0 +1,132 @@
defmodule Firehose.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, Firehose.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Firehose.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View File

@ -0,0 +1,84 @@
defmodule Firehose.Accounts.UserNotifier do
import Swoosh.Email
alias Firehose.Mailer
alias Firehose.Accounts.User
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"Firehose", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a user email.
"""
def deliver_update_email_instructions(user, url) do
deliver(user.email, "Update email instructions", """
==============================
Hi #{user.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(user, url) do
case user do
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
_ -> deliver_magic_link_instructions(user, url)
end
end
defp deliver_magic_link_instructions(user, url) do
deliver(user.email, "Log in instructions", """
==============================
Hi #{user.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end

View File

@ -0,0 +1,156 @@
defmodule Firehose.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Firehose.Accounts.UserToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, Firehose.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the user's email.
The non-hashed token is sent to the user email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the user changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%UserToken{
token: hashed_token,
context: context,
sent_to: sent_to,
user_id: user.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{user, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks whether the token has expired. The context
of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == user.email,
select: {user, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user_token found by the token, if any.
This is used to validate requests to change the user
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end

View File

@ -1,4 +1,7 @@
defmodule Firehose.EngineeringBlog do defmodule Firehose.EngineeringBlog do
@moduledoc """
Engineering blog configuration.
"""
use Blogex.Blog, use Blogex.Blog,
blog_id: :engineering, blog_id: :engineering,
app: :firehose, app: :firehose,

View File

@ -1,4 +1,7 @@
defmodule Firehose.ReleaseNotes do defmodule Firehose.ReleaseNotes do
@moduledoc """
Release notes blog configuration.
"""
use Blogex.Blog, use Blogex.Blog,
blog_id: :release_notes, blog_id: :release_notes,
app: :firehose, app: :firehose,

View File

@ -0,0 +1,31 @@
defmodule Firehose.Release do
@moduledoc """
Tasks for production releases (e.g., database migrations).
Usage from Dokku:
dokku run APP_NAME /app/bin/migrate
"""
@app :firehose
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View File

@ -88,8 +88,8 @@ defmodule FirehoseWeb do
import FirehoseWeb.CoreComponents import FirehoseWeb.CoreComponents
# Common modules used in templates # Common modules used in templates
alias Phoenix.LiveView.JS
alias FirehoseWeb.Layouts alias FirehoseWeb.Layouts
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil # Routes generation with the ~p sigil
unquote(verified_routes()) unquote(verified_routes())

View File

@ -29,6 +29,7 @@ defmodule FirehoseWeb.CoreComponents do
use Phoenix.Component use Phoenix.Component
use Gettext, backend: FirehoseWeb.Gettext use Gettext, backend: FirehoseWeb.Gettext
alias Phoenix.HTML.Form
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
@doc """ @doc """
@ -181,7 +182,7 @@ defmodule FirehoseWeb.CoreComponents do
def input(%{type: "checkbox"} = assigns) do def input(%{type: "checkbox"} = assigns) do
assigns = assigns =
assign_new(assigns, :checked, fn -> assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) Form.normalize_value("checkbox", assigns[:value])
end) end)
~H""" ~H"""

View File

@ -9,6 +9,7 @@ defmodule FirehoseWeb.Layouts do
# The default root.html.heex file contains the HTML # The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers # skeleton of your application, namely HTML headers
# and other static content. # and other static content.
embed_templates "layouts/*" embed_templates "layouts/*"
@doc """ @doc """

View File

@ -1,6 +1,9 @@
<header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-200"> <header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-200">
<div class="flex-1"> <div class="flex-1">
<a href="/" class="font-display text-xl font-semibold tracking-tight text-primary hover:opacity-80 transition"> <a
href="/"
class="font-display text-xl font-semibold tracking-tight text-primary hover:opacity-80 transition"
>
firehose firehose
</a> </a>
</div> </div>

View File

@ -22,11 +22,14 @@ defmodule FirehoseWeb.BlogController do
def show(conn, %{"slug" => slug}) do def show(conn, %{"slug" => slug}) do
blog = conn.assigns.blog blog = conn.assigns.blog
post = blog.get_post!(slug) post = blog.get_post!(slug)
visibility = Blogex.Post.visibility(post)
render(conn, :show, render(conn, :show,
page_title: post.title, page_title: post.title,
post: post, post: post,
base_path: blog.base_path() base_path: blog.base_path(),
visibility: visibility,
authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user)
) )
end end
@ -58,6 +61,7 @@ defmodule FirehoseWeb.BlogController do
end end
defp parse_page(nil), do: 1 defp parse_page(nil), do: 1
defp parse_page(str) do defp parse_page(str) do
case Integer.parse(str) do case Integer.parse(str) do
{page, ""} when page > 0 -> page {page, ""} when page > 0 -> page

View File

@ -1,4 +1,23 @@
<div class="space-y-8"> <div class="space-y-8">
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a> <a href={@base_path} class="text-sm text-primary hover:underline">&larr; Back to posts</a>
<.post_show post={@post} />
<%= if @authenticated and @visibility == :draft do %>
<div
class="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-amber-800 text-sm font-medium"
id="post-status-banner"
>
Draft — not published
</div>
<% end %>
<%= if @authenticated and @visibility == :scheduled do %>
<div
class="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-blue-800 text-sm font-medium"
id="post-status-banner"
>
This post is scheduled for {Calendar.strftime(@post.date, "%B %d, %Y")}
</div>
<% end %>
<.post_show post={@post} base_path={@base_path} />
</div> </div>

View File

@ -4,7 +4,7 @@
<p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p> <p class="mt-2 text-base-content/70">Posts tagged "{@tag}"</p>
</header> </header>
<.post_index posts={@posts} base_path={@base_path} /> <.post_index posts={@posts} base_path={@base_path} current_tag={@tag} />
<a href={@base_path} class="text-sm text-primary hover:underline">&larr; All posts</a> <a href={@base_path} class="text-sm text-primary hover:underline">&larr; All posts</a>
</div> </div>

View File

@ -6,7 +6,12 @@
<div class="space-y-4 text-lg leading-relaxed text-base-content/80"> <div class="space-y-4 text-lg leading-relaxed text-base-content/80">
<p> <p>
I'm <strong class="text-base-content">Willem van den Ende</strong>, I'm <strong class="text-base-content">Willem van den Ende</strong>,
partner at <a href="https://qwan.eu" class="text-primary hover:underline" target="_blank" rel="noopener">QWAN</a>. partner at <a
href="https://qwan.eu"
class="text-primary hover:underline"
target="_blank"
rel="noopener"
>QWAN</a>.
This is where I write about AI-native consulting, shitty evals, This is where I write about AI-native consulting, shitty evals,
and whatever prototype I'm building this week. and whatever prototype I'm building this week.
</p> </p>
@ -21,7 +26,9 @@
class="rounded-box border border-base-200 p-5 space-y-2 hover:border-primary/30 transition" class="rounded-box border border-base-200 p-5 space-y-2 hover:border-primary/30 transition"
> >
<a href={"#{post_base_path(post)}/#{post.id}"} class="block space-y-2"> <a href={"#{post_base_path(post)}/#{post.id}"} class="block space-y-2">
<h3 class="font-semibold text-base-content hover:text-primary transition">{post.title}</h3> <h3 class="font-semibold text-base-content hover:text-primary transition">
{post.title}
</h3>
<p class="text-sm text-base-content/60">{post.description}</p> <p class="text-sm text-base-content/60">{post.description}</p>
<div class="flex items-center gap-2 text-xs text-base-content/50"> <div class="flex items-center gap-2 text-xs text-base-content/50">
<time datetime={Date.to_iso8601(post.date)}> <time datetime={Date.to_iso8601(post.date)}>

View File

@ -0,0 +1,44 @@
defmodule FirehoseWeb.UserRegistrationController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias Firehose.Accounts.User
def new(conn, _params) do
changeset = Accounts.change_user_email(%User{})
render(conn, :new, changeset: changeset)
end
def create(conn, %{"user" => user_params}) do
allowed_email = Application.get_env(:firehose, :allowed_registration_email)
if allowed_email == nil or user_params["email"] != allowed_email do
changeset =
%User{}
|> Accounts.change_user_email(user_params)
|> Ecto.Changeset.add_error(:email, "registration is invite only.")
|> Map.put(:action, :validate)
render(conn, :new, changeset: changeset)
else
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
conn
|> put_flash(
:info,
"An email was sent to #{user.email}, please access it to confirm your account."
)
|> redirect(to: ~p"/users/log-in")
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, :new, changeset: changeset)
end
end
end
end

View File

@ -0,0 +1,5 @@
defmodule FirehoseWeb.UserRegistrationHTML do
use FirehoseWeb, :html
embed_templates "user_registration_html/*"
end

View File

@ -0,0 +1,30 @@
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
</div>

View File

@ -0,0 +1,88 @@
defmodule FirehoseWeb.UserSessionController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias FirehoseWeb.UserAuth
def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
render(conn, :new, form: form)
end
# magic link login
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
info =
case params do
%{"_action" => "confirmed"} -> "User confirmed successfully."
_ -> "Welcome back!"
end
case Accounts.login_user_by_magic_link(token) do
{:ok, {user, _expired_tokens}} ->
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
{:error, :not_found} ->
conn
|> put_flash(:error, "The link is invalid or it has expired.")
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
end
end
# email + password login
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, "Welcome back!")
|> UserAuth.log_in_user(user, user_params)
else
form = Phoenix.Component.to_form(user_params, as: "user")
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> render(:new, form: form)
end
end
# magic link request
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions for logging in shortly."
conn
|> put_flash(:info, info)
|> redirect(to: ~p"/users/log-in")
end
def confirm(conn, %{"token" => token}) do
if user = Accounts.get_user_by_magic_link_token(token) do
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
conn
|> assign(:user, user)
|> assign(:form, form)
|> render(:confirm)
else
conn
|> put_flash(:error, "Magic link is invalid or it has expired.")
|> redirect(to: ~p"/users/log-in")
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View File

@ -0,0 +1,9 @@
defmodule FirehoseWeb.UserSessionHTML do
use FirehoseWeb, :html
embed_templates "user_session_html/*"
defp local_mail_adapter? do
Application.get_env(:firehose, Firehose.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View File

@ -0,0 +1,57 @@
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>Welcome {@user.email}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the user settings.
</p>
</div>

View File

@ -0,0 +1,71 @@
<div class="mx-auto max-w-sm space-y-4">
<div class="text-center">
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
spellcheck="false"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
</div>

View File

@ -0,0 +1,77 @@
defmodule FirehoseWeb.UserSettingsController do
use FirehoseWeb, :controller
alias Firehose.Accounts
alias FirehoseWeb.UserAuth
import FirehoseWeb.UserAuth, only: [require_sudo_mode: 2]
plug :require_sudo_mode
plug :assign_email_and_password_changesets
def edit(conn, _params) do
render(conn, :edit)
end
def update(conn, %{"action" => "update_email"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
conn
|> put_flash(
:info,
"A link to confirm your email change has been sent to the new address."
)
|> redirect(to: ~p"/users/settings")
changeset ->
render(conn, :edit, email_changeset: %{changeset | action: :insert})
end
end
def update(conn, %{"action" => "update_password"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
case Accounts.update_user_password(user, user_params) do
{:ok, {user, _}} ->
conn
|> put_flash(:info, "Password updated successfully.")
|> put_session(:user_return_to, ~p"/users/settings")
|> UserAuth.log_in_user(user)
{:error, changeset} ->
render(conn, :edit, password_changeset: changeset)
end
end
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} ->
conn
|> put_flash(:info, "Email changed successfully.")
|> redirect(to: ~p"/users/settings")
{:error, _} ->
conn
|> put_flash(:error, "Email change link is invalid or it has expired.")
|> redirect(to: ~p"/users/settings")
end
end
defp assign_email_and_password_changesets(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user))
end
end

View File

@ -0,0 +1,5 @@
defmodule FirehoseWeb.UserSettingsHTML do
use FirehoseWeb, :html
embed_templates "user_settings_html/*"
end

View File

@ -0,0 +1,47 @@
<div class="text-center">
<.header>
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
</div>
<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
<input type="hidden" name="action" value="update_email" />
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
</.form>
<div class="divider" />
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
<input type="hidden" name="action" value="update_password" />
<.input
field={f[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={f[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.button variant="primary" phx-disable-with="Changing...">
Save Password
</.button>
</.form>

View File

@ -0,0 +1,117 @@
defmodule FirehoseWeb.EditorDashboardLive do
use FirehoseWeb, :live_view
alias Blogex.Post
@impl true
def mount(_params, _session, socket) do
all_posts = Blogex.Registry.all_posts_unfiltered()
drafts =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :draft))
|> Enum.sort_by(& &1.date, {:desc, Date})
scheduled =
all_posts
|> Enum.filter(&(Post.visibility(&1) == :scheduled))
|> Enum.sort_by(& &1.date, {:asc, Date})
{:ok,
socket
|> assign(:page_title, "Editor Dashboard")
|> assign(:drafts, drafts)
|> assign(:scheduled, scheduled)
|> assign(:active_tab, :drafts)}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="flex gap-4 mb-6 border-b border-zinc-200">
<button
phx-click="switch_tab"
phx-value-tab="drafts"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :drafts,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-zinc-500 hover:text-zinc-700"
)
]}
>
Drafts ({length(@drafts)})
</button>
<button
phx-click="switch_tab"
phx-value-tab="scheduled"
class={[
"pb-2 px-1 text-sm font-medium transition-colors",
if(@active_tab == :scheduled,
do: "border-b-2 border-zinc-900 text-zinc-900",
else: "text-zinc-500 hover:text-zinc-700"
)
]}
>
Scheduled ({length(@scheduled)})
</button>
</div>
<div id="drafts-tab" class={if(@active_tab != :drafts, do: "hidden")}>
<div :if={@drafts == []} class="text-zinc-500 text-sm">No drafts</div>
<div :for={post <- @drafts} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-zinc-900 hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-zinc-500 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} ·
<span class="text-amber-600 font-medium">Draft</span>
</div>
</div>
</div>
</div>
</div>
<div id="scheduled-tab" class={if(@active_tab != :scheduled, do: "hidden")}>
<div :if={@scheduled == []} class="text-zinc-500 text-sm">No scheduled posts</div>
<div :for={post <- @scheduled} class="py-4 border-b border-zinc-100 last:border-0">
<div class="flex items-center justify-between">
<div>
<.link
navigate={post_path(post)}
class="text-base font-medium text-zinc-900 hover:underline"
>
{post.title}
</.link>
<div class="text-sm text-zinc-500 mt-1">
{post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} ·
<span class="text-blue-600 font-medium">
{Post.days_until_live(post)} days until live
</span>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))}
end
defp post_path(post) do
blog = Blogex.Registry.get_blog!(post.blog)
"#{blog.base_path()}/#{post.id}"
end
end

View File

@ -1,6 +1,8 @@
defmodule FirehoseWeb.Router do defmodule FirehoseWeb.Router do
use FirehoseWeb, :router use FirehoseWeb, :router
import FirehoseWeb.UserAuth
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug :accepts, ["html"]
plug :fetch_session plug :fetch_session
@ -9,6 +11,7 @@ defmodule FirehoseWeb.Router do
plug :put_layout, html: {FirehoseWeb.Layouts, :app} plug :put_layout, html: {FirehoseWeb.Layouts, :app}
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end end
pipeline :api do pipeline :api do
@ -51,4 +54,35 @@ defmodule FirehoseWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview forward "/mailbox", Plug.Swoosh.MailboxPreview
end end
end end
## Authentication routes
scope "/", FirehoseWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
end
scope "/", FirehoseWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :authenticated_user,
on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
scope "/", FirehoseWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/log-in/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end end

View File

@ -0,0 +1,256 @@
defmodule FirehoseWeb.UserAuth do
use FirehoseWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Firehose.Accounts
alias Firehose.Accounts.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_firehose_web_user_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
conn
|> create_or_extend_session(user, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
FirehoseWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
else
nil
end
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, user, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn, _user) do
# delete_csrf_token()
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn, _user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:user_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
put_session(conn, :user_token, token)
end
@doc """
Plug for routes that require sudo mode.
"""
def require_sudo_mode(conn, _opts) do
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
conn
else
conn
|> put_flash(:error, "You must re-authenticate to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
@doc """
Plug for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns.current_scope do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
defp signed_in_path(_conn), do: ~p"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
@doc """
LiveView on_mount callback that ensures the user is authenticated.
Used in `live_session` blocks in the router:
live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do
live "/editor/dashboard", EditorDashboardLive
end
"""
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
if token = session["user_token"] do
case Accounts.get_user_by_session_token(token) do
{user, _token_inserted_at} -> Scope.for_user(user)
nil -> Scope.for_user(nil)
end
else
Scope.for_user(nil)
end
end)
end
end

View File

@ -0,0 +1,49 @@
defmodule Firehose.Checks.NoConnShadowing do
use Credo.Check,
base_priority: :normal,
category: :readability,
explanations: [
check: """
Conn shadowing (`conn = get(conn, ...)`) makes Phoenix controller tests
noisy. Use pipe chains instead:
body = conn |> get("/path") |> html_response(200)
Run `./refactor_conn_aliasing.sh <file>` to fix automatically.
"""
]
@http_verbs ~w(get post put patch delete head options)a
@impl true
def run(%SourceFile{} = source_file, params) do
issue_meta = IssueMeta.for(source_file, params)
source_file
|> Credo.Code.prewalk(&traverse(&1, &2, issue_meta))
|> Enum.reverse()
end
defp traverse(
{:=, meta, [{:conn, _, _}, {verb, _, [{:conn, _, _} | _]}]} = ast,
issues,
issue_meta
)
when verb in @http_verbs do
issue = issue_for(issue_meta, meta[:line], verb)
{ast, [issue | issues]}
end
defp traverse(ast, issues, _issue_meta) do
{ast, issues}
end
defp issue_for(issue_meta, line_no, verb) do
format_issue(
issue_meta,
message:
"Conn shadowing detected (`conn = #{verb}(conn, ...)`). Run `./refactor_conn_aliasing.sh <file>` to fix.",
line_no: line_no
)
end
end

View File

@ -32,7 +32,8 @@ defmodule Firehose.MixProject do
end end
# Specifies which paths to compile per environment. # Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(:test), do: ["lib", "lib_dev", "test/support"]
defp elixirc_paths(:dev), do: ["lib", "lib_dev"]
defp elixirc_paths(_), do: ["lib"] defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies. # Specifies your project dependencies.
@ -40,6 +41,7 @@ defmodule Firehose.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.8.1"}, {:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"}, {:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"}, {:ecto_sql, "~> 3.13"},
@ -66,7 +68,8 @@ defmodule Firehose.MixProject do
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"}, {:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:blogex, path: "../blogex"} {:blogex, path: "../blogex"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
] ]
end end

View File

@ -1,6 +1,10 @@
%{ %{
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},

View File

@ -1,6 +1,7 @@
%{ %{
title: "Hello World", title: "Hello World",
author: "Firehose Team", author: "Firehose Team",
published: false,
tags: ~w(elixir phoenix), tags: ~w(elixir phoenix),
description: "Our first engineering blog post" description: "Our first engineering blog post"
} }

View File

@ -7,20 +7,24 @@
} }
--- ---
I wrote about [publishing short posts](https://www.qwan.eu/2025/05/20/publish-short-posts.html) on the QWAN blog last year. Giving myself license to write shorter, rougher pieces. That worked for a while. But some things don't belong on a consultancy blog. I wrote about [publishing short posts](https://www.qwan.eu/2025/05/20/publish-short-posts.html) on the QWAN blog last year. Giving myself license to write shorter, rougher pieces. That worked for a while. But some things don't feel like a good fit for the QWAN blog just yet.
When I prototype with a coding agent at 11pm, the thing I learn is not a polished QWAN insight. It's a half-formed observation about evals, or a trick for keeping the human in the loop, or just "I built this and here's what surprised me." The QWAN blog has a certain standard. This stuff needs somewhere scruffier to land. This post was partially written with claude code, see the commit history [on our gitea](https://gitea.apps.sustainabledelivery.com/mostalive/firehose) if you want to check the differences.
Hence **Firehose** — named after what it feels like to work with AI coding agents. You're drinking from a firehose of generated code, suggestions, and decisions. The interesting question is not "how do I generate more" but "how do I stay in control of what's coming out." When I prototype with a coding agent at 11pm (I should go to bed and write a post about sustainable pace the next day ;-) ), the thing I learn is not a polished QWAN insight. It's a half-formed observation about something that just happened, or a trick for keeping the human in the loop, or just "I built this and here's what surprised me."
That's also what this site is built with, by the way. The homepage, the blog engine, the layout — all built in conversation with Claude Code. I wanted to experience what our clients experience: shipping something real with an AI agent, and noticing where the friction is. Hence **Firehose** — named after what it feels like to work with AI coding agents. You're drinking from a firehose of generated code, suggestions, and decisions. The interesting question is not "how do I generate more" but "how do I stay in control of what's coming out.". And also, currently, how do I generate just enough and focus on interesting feedback loops instead of code?
A few things I noticed, building this: I wrote this last may as well [shallow research tool](https://www.qwan.eu/2025/05/01/agentic-search.html):
- **Layout inheritance is a design decision.** The blog engine rendered pages outside Phoenix's layout pipeline. Getting navbar and CSS onto blog pages meant rethinking how the pieces fit together — not just adding a wrapper div. > I want to both get better at using LLMs for programming, and also understand how they work. Marc suggested earlier this year that I write a series of blog posts about my use of them, but I have been drinking from a firehose, and it is quite difficult to figure out a good place to start writing.
- **Warm aesthetics take intention.** The default Phoenix boilerplate is fine, but it says nothing about who you are. Choosing fonts and colours forced me to think about what "personal but professional" looks like.
- **It's fast when it works, and confusing when it doesn't.** When the agent understands your stack, you move at extraordinary speed. When it doesn't (say, the difference between `@inner_block` and `@inner_content` in Phoenix layouts), you can burn time on a misunderstanding that a human would catch in seconds. I have made good progress in learning, and at the same time, practices are still evolving. I see people write patterns. I think it is useful, but too early for that. I am at heuristics (rules of thumb).
That is also why I open sourced the code for this blog [firehose repository on our gitea](https://gitea.apps.sustainabledelivery.com/mostalive/firehose). I think Jekyll, the static site generator we have for QWAN is passable, but I want the option to have a more interactive blog, and since this is going to be a firehose of ideas, give readers the option to subscribe to only what they are interested in, filter posts, like etc. I helped a friend with 'Ghost', but it felt clunky. I like writing in plain text and publishing with `git push` - that works with Jekyll and other static site generators.
I am exploring working in small slices. That does require some initial investment in modularity. If you look at the code, you will notice that some of the blogging functionality is separate from the main site. I want an 'engineering blog' and 'release notes' as a plugin for Software as a Service applications.
This is the space I want to write in. Shorter than a conference talk, longer than a LinkedIn post. Honest about what works and what doesn't. This is the space I want to write in. Shorter than a conference talk, longer than a LinkedIn post. Honest about what works and what doesn't.
If you're a CTO or engineering lead wondering what "AI-native development" actually looks like day to day — not the vendor pitch, the lived experience — that's what I'll be writing about here. If you're wondering what "AI-native development" actually looks like day to day — not the vendor pitch, the lived experience — that's what I'll be writing about here.

View File

@ -0,0 +1,12 @@
%{
title: "Coding agent from scratch - a loop with tools, not that complicated",
author: "Willem van den Ende",
published: true,
tags: ~w(llm coding-agent python exercise),
description: "Coding agents are not that complicated. A loop with some tools. I found an interactive tutorial that lets you experience it"
}
---
I had started on a "Write your own coding agent" exercise. Four iterations in, actually. And then I found [Tiny Agents]( https://tinyagents.dev/lesson/agent-loop), a set of interactive exercises that let you experience how agents work, from a simple chat request, through a tool, more tools etc. It has a live graph, that visualises of the flow of data and actions.
It is good fun to play with, it starts simple and builds up. It lets you inspect the messages between the 'agent' loop code and the large language model server (which is just HTTP and some JSON).

View File

@ -0,0 +1,46 @@
%{
title: "Blog post triage with a local coding agent",
author: "Willem van den Ende",
published: true,
tags: ~w(llm coding-agent blogging),
description: "Can a coding agent help me get some of my draft blog posts over the line? I followed a tip by Chris Parsons to find out."
}
---
I made a skill for a coding agent to help me get more of my draft blog posts over the line. I enjoy writing, and am somewhat fluent in it. Publishing that writing is more hit and miss, however. I often lose energy just before a piece is finished enough. I want to publish more often, and need to form a more effective habit for it.
# What did I get out if it?
I got a working agent 'skill' in an hour or so. I like the QWEN models for their no-bullshit approach to feedback. As it turns out, I have about 60 pages with the 'Candidate Blogpost' tag in my notes, but most of them are not more than an idea. Only some of them have enough detail to turn into a post. I am going to keep this around, prune my candidate blogposts, and add my recent clippings to the mix.
Quite a few of my candidates were 'just links' according to the model, but as I am inspired by [Simon Willison](https://www.simonwillison.net), there is value in sharing links with a brief description on why I think they are relevant. Probably in a different category.
# How did I develop the skill?
I was inspired by two writings:
- Jurgen De Smet asking [how do you write long form articles?](https://www.linkedin.com/posts/jurgendesmet_this-is-how-i-write-long-form-articles-these-share-7441394036222935040-JHmv).
- Chris Parsons suggested to [brief an agent for daily tasks](https://www.chrismdp.com/stop-prompting-start-briefing/), and use the _backbriefing_ loop from "The Art of Action" to improve them.
I like "The Art of Action" - detailed, yet practical. So I had a chat with a frontier model to develop a skill for a local model to surface notes that are almost finished, with some suggestions to get them over the line.
This was my initial prompt. Full chat transcript in the Further Reading section.
#+begin_quote
https://www.chrismdp.com/stop-prompting-start-briefing/ suggests an art of action style backbriefing loop for daily work. I would like to use a local model with pi, the shitty coding agent, instead of claude code. I have trouble publishing blogposts. I have many drafts, marked as CandidateBlogPost in an org-roam directory. I wonder if I could make some kind of pi extension or skill that finds candidate blogposts, helps identify ones that are almost finished, with a suggesion on what to do next for the top 3 almost finished, and suggestions for others on what to add. Probably prioritize recency. I could run that as a cron job in the morning, and create a new daily entry (I use daily entries for org-roam) to get me starte.d Goal would be not to have AI write my posts, but help me finish in pomodori instead of days.
#+end_quote
What I found interesting was that, maybe because I mentioned the links were in an sqlite database, claude desktop spontaneously suggested to create a bash script as part of the skill. I used to have a meta-skill to separate the deterministic parts of agent skills into scripts, but that does not seem to be necessary anymore. I prune my agent setups continuously, only keeping what is needed.
# Tradeoffs
Initially I planned to run this as a scheduled job, but from the development chat it emerged that backbriefing (improving the skill as we run it daily) would not work if it runs scheduled.
I chose a local coding agent with a local model, because I don't want to share my personal notes with a cloud service, and I thought that a smaller model would be more than powerful enough.
## Further reading
https://claude.ai/share/be0184d9-f2bf-41ba-b2e3-235fe9daf9fd - initial chat do develop the skill
I will share a repository with the skill later. I think it is more instructive to have a look at the prompt, and make one for your own notes, starting from your own goals.

View File

@ -0,0 +1,8 @@
%{
title: "Future Test Post",
author: "Test Author",
tags: ~w(test),
description: "A post scheduled for the future"
}
---
This is a future test post.

View File

@ -0,0 +1,72 @@
%{
title: "Scheduled Publishing & Author Dashboard",
author: "Willem van den Ende",
tags: ~w(release features),
description: "Future-dated posts stay hidden until their publish date, authors get a dashboard to track drafts and scheduled content, and registration is locked down to invited emails only."
}
---
Posts in Firehose are markdown files with a date in the filename. Until now, every published post was immediately visible. That changes today: posts with a future date are now hidden from public views until their date arrives.
This was built in a single session using an agentic dev team -- 12 issues tracked in beads, executed in three parallel phases, producing 232 tests across the blogex library and Phoenix app.
## What changed
### Future-dated posts are hidden from public views
The blog index, tag pages, RSS feeds, and Atom feeds now filter out posts where the date is after today. If you schedule a post for next Tuesday, readers won't see it until then.
![Blog index showing only past-dated posts](/images/scheduled-publishing/scheduled-blog-index.png)
But here's the key design choice: **direct URL access still works**. If you know the slug, you can view the post. This lets authors share preview links with reviewers before the publish date.
![Future post accessible by direct URL](/images/scheduled-publishing/scheduled-direct-access.png)
### Status banners for authors
When you're logged in, draft and scheduled posts show a status banner so you always know what state a post is in. Unauthenticated visitors see nothing -- no clue the post isn't "live" yet.
**Scheduled posts** show a blue banner with the target date:
![Scheduled banner showing target publish date](/images/scheduled-publishing/scheduled-banner-future.png)
**Draft posts** (unpublished) show an amber banner:
![Draft banner on unpublished post](/images/scheduled-publishing/scheduled-banner-draft.png)
### Editor dashboard
A new LiveView at `/editor/dashboard` gives authors a unified view of all non-live content across every blog. Two tabs: drafts and scheduled posts. Scheduled posts show a "days until live" countdown.
![Dashboard drafts tab](/images/scheduled-publishing/scheduled-dashboard.png)
![Dashboard scheduled tab with countdown](/images/scheduled-publishing/scheduled-dashboard-scheduled.png)
The dashboard requires authentication -- unauthenticated users are redirected to the login page.
### Authentication and registration gating
We added `mix phx.gen.auth` for session-based authentication with magic links and password login. Login and registration pages are accessible by direct URL only -- they're intentionally not linked from the public navigation.
Registration is gated to a single email via the `ALLOWED_REGISTRATION_EMAIL` environment variable. Anyone else gets a polite rejection:
![Registration rejected for non-matching email](/images/scheduled-publishing/scheduled-registration-rejected.png)
When the environment variable isn't set, registration is disabled entirely. A demo user (`demo@example.com`) is seeded in dev for local testing.
## How it was built
The feature was planned as an [Allium specification](https://github.com/your-org/allium) with surfaces, rules, and domain entities, then broken into 12 beads (issues) across three phases:
1. **Scheduled posts** (5 beads): date filtering in blogex, unfiltered direct access, feed/router verification
2. **Authentication** (3 beads): phx.gen.auth scaffolding, registration gating, dev seed
3. **Dashboard** (4 beads): post visibility helpers, unfiltered registry access, LiveView dashboard, status banners
All 12 beads were executed with parallel agentic workers in isolated git worktrees, then merged and integrated on main. The demo caught one bug (auth check using `current_user` instead of `current_scope`) which was fixed before this post.
## By the numbers
- **232 tests** passing (89 blogex + 143 Phoenix app)
- **12 beads** planned, executed, and closed
- **3 phases** run with parallel workers
- **0 compiler warnings**

View File

@ -0,0 +1,30 @@
defmodule Firehose.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string
add :confirmed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end

View File

@ -9,3 +9,13 @@
# #
# We recommend using the bang functions (`insert!`, `update!` # We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong. # and so on) as they will fail if something goes wrong.
if Mix.env() == :dev do
alias Firehose.Accounts
# Create demo user if not already present
unless Accounts.get_user_by_email("demo@example.com") do
{:ok, user} = Accounts.register_user(%{email: "demo@example.com"})
{:ok, {_user, _tokens}} = Accounts.update_user_password(user, %{password: "password123!"})
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

6
app/rel/overlays/bin/migrate Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"/..
exec ./bin/firehose eval Firehose.Release.migrate

6
app/rel/overlays/bin/server Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"/..
PHX_SERVER=true exec ./bin/firehose start

View File

@ -0,0 +1,397 @@
defmodule Firehose.AccountsTest do
use Firehose.DataCase
alias Firehose.Accounts
import Firehose.AccountsFixtures
alias Firehose.Accounts.{User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture() |> set_password()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture() |> set_password()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!(-1)
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
test "validates email when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid"})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum values for email for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Accounts.register_user(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the uppercased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users without password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_nil(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
end
end
describe "sudo_mode?/2" do
test "validates the authenticated_at time" do
now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
# minute override
refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)},
-10
)
# not authenticated
refute Accounts.sudo_mode?(%User{})
end
end
describe "change_user_email/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
assert changeset.required == [:email]
end
end
describe "deliver_user_update_email_instructions/3" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "change:current@example.com"
end
end
describe "update_user_email/2" do
setup do
user = unconfirmed_user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{user: user, token: token, email: email}
end
test "updates the email with a valid token", %{user: user, token: token, email: email} do
assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token)
changed_user = Repo.get!(User, user.id)
assert changed_user.email != user.email
assert changed_user.email == email
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email with invalid token", %{user: user} do
assert Accounts.update_user_email(user, "oops") ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if user email changed", %{user: user, token: token} do
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.update_user_email(user, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "change_user_password/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(
%User{},
%{
"password" => "new valid password"
},
hash_password: false
)
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, {user, expired_tokens}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
assert expired_tokens == []
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, {_, _}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
assert user_token.authenticated_at != nil
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at
assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
assert session_user.authenticated_at != nil
assert token_inserted_at != nil
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
dt = ~N[2020-01-01 00:00:00]
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
refute Accounts.get_user_by_session_token(token)
end
end
describe "get_user_by_magic_link_token/1" do
setup do
user = user_fixture()
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
%{user: user, token: encoded_token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Accounts.get_user_by_magic_link_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_magic_link_token("oops")
end
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_magic_link_token(token)
end
end
describe "login_user_by_magic_link/1" do
test "confirms user and expires tokens" do
user = unconfirmed_user_fixture()
refute user.confirmed_at
{encoded_token, hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {user, [%{token: ^hashed_token}]}} =
Accounts.login_user_by_magic_link(encoded_token)
assert user.confirmed_at
end
test "returns user and (deleted) token for confirmed user" do
user = user_fixture()
assert user.confirmed_at
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token)
# one time use only
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
Accounts.login_user_by_magic_link(encoded_token)
end
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "deliver_login_instructions/2" do
setup do
%{user: unconfirmed_user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "login"
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end

View File

@ -0,0 +1,73 @@
defmodule FirehoseWeb.BlogControllerTest do
use FirehoseWeb.ConnCase, async: false
describe "GET /blog/:blog_id (index) - date filtering" do
test "does not show future-dated posts", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering")
html = html_response(conn, 200)
refute html =~ "Future Test Post"
end
end
describe "GET /blog/:blog_id/:slug (show) - date filtering" do
test "still shows a future-dated post by slug", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering/future-test-post")
assert html_response(conn, 200) =~ "Future Test Post"
end
end
describe "GET /blog/:blog_id/tag/:tag - date filtering" do
test "excludes future-dated posts from tag page", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering/tag/test")
html = html_response(conn, 200)
refute html =~ "Future Test Post"
end
end
describe "GET /blog/:blog_id/:slug - status banners" do
setup :register_and_log_in_user
test "authenticated user sees draft banner on draft post", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering/hello-world")
assert html_response(conn, 200) =~ "Draft"
assert conn.resp_body =~ "not published"
end
test "authenticated user sees scheduled banner on future post", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering/future-test-post")
response = html_response(conn, 200)
assert response =~ "scheduled for"
assert response =~ "January 01, 2099"
end
test "authenticated user sees no banner on live post", %{conn: conn} do
conn = get(conn, ~p"/blog/engineering/why-firehose")
response = html_response(conn, 200)
refute response =~ "Draft"
refute response =~ "scheduled for"
end
end
describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do
test "unauthenticated user sees no banner on draft post", %{conn: conn} do
response =
conn
|> get(~p"/blog/engineering/hello-world")
|> html_response(200)
refute response =~ "post-status-banner"
end
test "unauthenticated user sees no banner on future post", %{conn: conn} do
response =
conn
|> get(~p"/blog/engineering/future-test-post")
|> html_response(200)
refute response =~ "post-status-banner"
end
end
end

View File

@ -0,0 +1,123 @@
defmodule FirehoseWeb.BlogTagsTest do
use FirehoseWeb.ConnCase
defp goto_engineering_tag_page(conn, tag) do
path = "/blog/engineering/tag/#{tag}"
conn_res = get(conn, path)
body = html_response(conn_res, 200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Engineering Blog"
body
end
defp goto_releases_tag_page(conn, tag) do
path = "/blog/releases/tag/#{tag}"
conn_res = get(conn, path)
body = html_response(conn_res, 200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Release Notes"
body
end
describe "engineering blog tags" do
test "GET /blog/engineering/tag/:tag shows tag page with all posts", %{conn: conn} do
body = goto_engineering_tag_page(conn, "elixir")
assert body =~ "Hello World"
end
test "GET /blog/engineering/tag/:tag page shows filtered posts", %{conn: conn} do
body = goto_engineering_tag_page(conn, "phoenix")
assert body =~ "Hello World"
end
test "GET /blog/engineering/tag/:tag page shows empty list for nonexistent tag", %{
conn: conn
} do
conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag")
assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag")
end
end
describe "release notes blog tags" do
test "GET /blog/releases/tag/:tag shows tag page with all posts", %{conn: conn} do
body = goto_releases_tag_page(conn, "release")
assert body =~ "v0.1.0 Released"
end
test "GET /blog/releases/tag/:tag page shows filtered posts", %{conn: conn} do
conn_res = get(conn, "/blog/releases/tag/nonexistent-tag")
assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag")
end
end
describe "tag URL pattern" do
test "tag URLs follow pattern /blog/:blog_id/tag/:tag for engineering blog", %{conn: conn} do
# Test that the tag route exists and works correctly
conn_res1 = get(conn, "/blog/engineering/tag/elixir")
assert html_response(conn_res1, 200) =~ ~s(tagged "elixir")
conn_res2 = get(conn, "/blog/engineering/tag/phoenix")
assert html_response(conn_res2, 200) =~ ~s(tagged "phoenix")
end
test "tag URLs follow pattern /blog/:blog_id/tag/:tag for releases blog", %{conn: conn} do
# Test that the tag route exists and works correctly
conn_res = get(conn, "/blog/releases/tag/release")
assert html_response(conn_res, 200) =~ ~s(tagged "release")
end
test "nonexistent tags return 200 with empty post list", %{conn: conn} do
conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag")
assert html_response(conn_res, 200)
end
end
describe "tag page structure" do
test "tag page has proper layout and back link", %{conn: conn} do
body = goto_engineering_tag_page(conn, "elixir")
assert body =~ "Engineering Blog"
assert body =~ ~s(tagged "elixir")
assert body =~ "All posts"
end
test "release tag page has proper layout and back link", %{conn: conn} do
body = goto_releases_tag_page(conn, "release")
assert body =~ "Release Notes"
assert body =~ ~s(tagged "release")
assert body =~ "All posts"
end
end
describe "clickable tags on index page" do
test "tags are rendered as clickable links on engineering blog index", %{
conn: conn
} do
conn_res1 = get(conn, "/blog/engineering")
body1 = html_response(conn_res1, 200)
# Verify tag links exist with correct href pattern
assert body1 =~ ~r{href="/blog/engineering/tag/meta"}
assert body1 =~ ~r{href="/blog/engineering/tag/ai"}
end
test "tags are rendered as clickable links on releases blog index", %{
conn: conn
} do
conn_res2 = get(conn, "/blog/releases")
body2 = html_response(conn_res2, 200)
# Verify tag link exists
assert body2 =~ ~r{href="/blog/releases/tag/release"}
end
test "tag links have proper styling classes", %{conn: conn} do
conn_res3 = get(conn, "/blog/engineering")
body3 = html_response(conn_res3, 200)
# Verify blogex-tag-link class is present for tag links
assert body3 =~ ~r{class="[^"]*blogex-tag-link}
end
end
end

View File

@ -1,50 +1,54 @@
# Firehose blog controller tests
defmodule FirehoseWeb.BlogTest do defmodule FirehoseWeb.BlogTest do
use FirehoseWeb.ConnCase use FirehoseWeb.ConnCase
defp visit_engineering_page(conn, suffix \\ "") do
path = "/blog/engineering" <> suffix
body = conn |> get(path) |> html_response(200)
assert body =~ "Engineering Blog"
assert body =~ "firehose"
body
end
defp visit_engineering_path(conn, suffix) do
path = "/blog/engineering" <> suffix
body = conn |> get(path) |> html_response(200)
assert body =~ "firehose"
body
end
describe "engineering blog (HTML)" do describe "engineering blog (HTML)" do
test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do
conn = get(conn, "/blog/engineering") visit_engineering_page(conn)
body = html_response(conn, 200)
assert body =~ "Engineering Blog"
assert body =~ "Hello World"
# Verify app layout is present (navbar)
assert body =~ "firehose"
end end
test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do
conn = get(conn, "/blog/engineering/hello-world") body = visit_engineering_path(conn, "/hello-world")
body = html_response(conn, 200)
assert body =~ "Hello World" assert body =~ "Hello World"
assert body =~ "firehose"
end end
test "GET /blog/engineering/tag/:tag returns HTML tag page", %{conn: conn} do test "GET /blog/engineering/tag/:tag returns HTML tag page", %{conn: conn} do
conn = get(conn, "/blog/engineering/tag/elixir") body = visit_engineering_path(conn, "/tag/elixir")
body = html_response(conn, 200)
assert body =~ ~s(tagged "elixir") assert body =~ ~s(tagged "elixir")
assert body =~ "Hello World"
end end
end end
describe "input validation" do describe "input validation" do
test "GET /blog/nonexistent returns 404", %{conn: conn} do test "GET /blog/nonexistent returns 404", %{conn: conn} do
conn = get(conn, "/blog/nonexistent") assert conn |> get("/blog/nonexistent") |> html_response(404)
assert html_response(conn, 404)
end end
test "GET /blog/engineering?page=abc falls back to page 1", %{conn: conn} do test "GET /blog/engineering?page=abc falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=abc") assert conn |> get("/blog/engineering?page=abc") |> html_response(200) =~ "Engineering Blog"
assert html_response(conn, 200) =~ "Engineering Blog"
end end
test "GET /blog/engineering?page=-1 falls back to page 1", %{conn: conn} do test "GET /blog/engineering?page=-1 falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=-1") assert conn |> get("/blog/engineering?page=-1") |> html_response(200) =~ "Engineering Blog"
assert html_response(conn, 200) =~ "Engineering Blog"
end end
test "GET /blog/engineering?page=0 falls back to page 1", %{conn: conn} do test "GET /blog/engineering?page=0 falls back to page 1", %{conn: conn} do
conn = get(conn, "/blog/engineering?page=0") assert conn |> get("/blog/engineering?page=0") |> html_response(200) =~ "Engineering Blog"
assert html_response(conn, 200) =~ "Engineering Blog"
end end
test "GET /blog/engineering/nonexistent-post returns 404", %{conn: conn} do test "GET /blog/engineering/nonexistent-post returns 404", %{conn: conn} do
@ -56,61 +60,100 @@ defmodule FirehoseWeb.BlogTest do
describe "release notes blog (HTML)" do describe "release notes blog (HTML)" do
test "GET /blog/releases returns HTML index", %{conn: conn} do test "GET /blog/releases returns HTML index", %{conn: conn} do
conn = get(conn, "/blog/releases") body = conn |> get("/blog/releases") |> html_response(200)
body = html_response(conn, 200)
assert body =~ "Release Notes" assert body =~ "Release Notes"
assert body =~ "v0.1.0 Released" assert body =~ "v0.1.0 Released"
end end
test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do
conn = get(conn, "/blog/releases/v0-1-0") body = conn |> get("/blog/releases/v0-1-0") |> html_response(200)
body = html_response(conn, 200)
assert body =~ "v0.1.0 Released" assert body =~ "v0.1.0 Released"
end end
test "GET /blog/releases/tag/:tag returns HTML tag page", %{conn: conn} do
body = conn |> get("/blog/releases/tag/elixir") |> html_response(200)
assert body =~ ~s(tagged "elixir")
end
end end
describe "engineering blog (JSON API)" do describe "engineering blog (JSON API)" do
test "GET /api/blog/engineering returns post index", %{conn: conn} do test "GET /api/blog/engineering returns post index", %{conn: conn} do
conn = get(conn, "/api/blog/engineering") assert %{"blog" => "engineering", "posts" => posts} =
assert %{"blog" => "engineering", "posts" => posts} = json_response(conn, 200) conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/engineering")
|> json_response(200)
assert is_list(posts) assert is_list(posts)
assert length(posts) > 0 refute Enum.empty?(posts)
end end
test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do
conn = get(conn, "/api/blog/engineering/hello-world") assert %{"id" => "hello-world", "title" => "Hello World"} =
assert %{"id" => "hello-world", "title" => "Hello World"} = json_response(conn, 200) conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/engineering/hello-world")
|> json_response(200)
end end
test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do
conn = get(conn, "/api/blog/engineering/nonexistent") assert conn
assert response(conn, 404) |> put_req_header("accept", "application/json")
|> get("/api/blog/engineering/nonexistent")
|> response(404)
end end
test "GET /api/blog/engineering/feed.xml returns RSS", %{conn: conn} do test "GET /api/blog/engineering/feed.xml returns RSS", %{conn: conn} do
conn = get(conn, "/api/blog/engineering/feed.xml") response = conn |> get("/api/blog/engineering/feed.xml")
assert response_content_type(conn, :xml) assert response(response, 200) =~ "<rss"
assert response(conn, 200) =~ "<rss" assert response_content_type(response, :xml)
end
test "GET /api/blog/engineering/tag/:tag returns JSON with posts", %{conn: conn} do
assert %{"blog" => "engineering", "tag" => "elixir", "posts" => posts} =
conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/engineering/tag/elixir")
|> json_response(200)
assert is_list(posts)
end end
end end
describe "release notes blog (JSON API)" do describe "release notes blog (JSON API)" do
test "GET /api/blog/releases returns post index", %{conn: conn} do test "GET /api/blog/releases returns post index", %{conn: conn} do
conn = get(conn, "/api/blog/releases") assert %{"blog" => "release_notes", "posts" => posts} =
assert %{"blog" => "release_notes", "posts" => posts} = json_response(conn, 200) conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/releases")
|> json_response(200)
assert is_list(posts) assert is_list(posts)
assert length(posts) > 0 refute Enum.empty?(posts)
end end
test "GET /api/blog/releases/:slug returns a post", %{conn: conn} do test "GET /api/blog/releases/:slug returns a post", %{conn: conn} do
conn = get(conn, "/api/blog/releases/v0-1-0") assert %{"id" => "v0-1-0", "title" => "v0.1.0 Released"} =
assert %{"id" => "v0-1-0", "title" => "v0.1.0 Released"} = json_response(conn, 200) conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/releases/v0-1-0")
|> json_response(200)
end end
test "GET /api/blog/releases/feed.xml returns RSS", %{conn: conn} do test "GET /api/blog/releases/feed.xml returns RSS", %{conn: conn} do
conn = get(conn, "/api/blog/releases/feed.xml") response = conn |> get("/api/blog/releases/feed.xml")
assert response_content_type(conn, :xml) assert response(response, 200) =~ "<rss"
assert response(conn, 200) =~ "<rss" assert response_content_type(response, :xml)
end
test "GET /api/blog/releases/tag/:tag returns JSON with posts", %{conn: conn} do
assert %{"blog" => "release_notes", "tag" => "elixir", "posts" => posts} =
conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/releases/tag/elixir")
|> json_response(200)
assert is_list(posts)
end end
end end
end end

View File

@ -2,8 +2,7 @@ defmodule FirehoseWeb.PageControllerTest do
use FirehoseWeb.ConnCase use FirehoseWeb.ConnCase
test "GET /", %{conn: conn} do test "GET /", %{conn: conn} do
conn = get(conn, ~p"/") body = conn |> get(~p"/") |> html_response(200)
body = html_response(conn, 200)
assert body =~ "Drinking from the firehose" assert body =~ "Drinking from the firehose"
assert body =~ "Willem van den Ende" assert body =~ "Willem van den Ende"
end end

View File

@ -0,0 +1,80 @@
defmodule FirehoseWeb.UserRegistrationControllerTest do
use FirehoseWeb.ConnCase, async: true
import Firehose.AccountsFixtures
describe "GET /users/register" do
test "renders registration page", %{conn: conn} do
conn = get(conn, ~p"/users/register")
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ ~p"/users/log-in"
assert response =~ ~p"/users/register"
end
test "redirects if already logged in", %{conn: conn} do
conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register")
assert redirected_to(conn) == ~p"/"
end
end
describe "POST /users/register" do
@tag :capture_log
test "creates account but does not log in", %{conn: conn} do
email = unique_user_email()
Application.put_env(:firehose, :allowed_registration_email, email)
on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end)
conn =
post(conn, ~p"/users/register", %{
"user" => valid_user_attributes(email: email)
})
refute get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/users/log-in"
assert conn.assigns.flash["info"] =~
~r/An email was sent to .*, please access it to confirm your account/
end
test "render errors for invalid data", %{conn: conn} do
Application.put_env(:firehose, :allowed_registration_email, "with spaces")
on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end)
conn =
post(conn, ~p"/users/register", %{
"user" => %{"email" => "with spaces"}
})
response = html_response(conn, 200)
assert response =~ "Register"
assert response =~ "must have the @ sign and no spaces"
end
end
describe "POST /users/register with email gating" do
test "succeeds when email matches ALLOWED_REGISTRATION_EMAIL", %{conn: conn} do
Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com")
on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end)
conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "allowed@example.com"}})
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "email was sent"
end
test "fails with invite-only message when email doesn't match", %{conn: conn} do
Application.put_env(:firehose, :allowed_registration_email, "allowed@example.com")
on_exit(fn -> Application.delete_env(:firehose, :allowed_registration_email) end)
conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "other@example.com"}})
assert html_response(conn, 200) =~ "registration is invite only"
end
test "fails with invite-only message when env var is unset", %{conn: conn} do
Application.delete_env(:firehose, :allowed_registration_email)
conn = post(conn, ~p"/users/register", %{"user" => %{"email" => "anyone@example.com"}})
assert html_response(conn, 200) =~ "registration is invite only"
end
end
end

View File

@ -0,0 +1,199 @@
defmodule FirehoseWeb.UserSessionControllerTest do
use FirehoseWeb.ConnCase, async: true
import Firehose.AccountsFixtures
alias Firehose.Accounts
setup do
%{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()}
end
describe "GET /users/log-in" do
test "renders login page", %{conn: conn} do
conn = get(conn, ~p"/users/log-in")
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
end
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
html =
conn
|> log_in_user(user)
|> get(~p"/users/log-in")
|> html_response(200)
assert html =~ "You need to reauthenticate"
refute html =~ "Register"
assert html =~ "Log in with email"
assert html =~
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
end
test "renders login page (email + password)", %{conn: conn} do
conn = get(conn, ~p"/users/log-in?mode=password")
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ ~p"/users/register"
assert response =~ "Log in with email"
end
end
describe "GET /users/log-in/:token" do
test "renders confirmation page for unconfirmed user", %{conn: conn, unconfirmed_user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
conn = get(conn, ~p"/users/log-in/#{token}")
assert html_response(conn, 200) =~ "Confirm and stay logged in"
end
test "renders login page for confirmed user", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
conn = get(conn, ~p"/users/log-in/#{token}")
html = html_response(conn, 200)
refute html =~ "Confirm my account"
assert html =~ "Keep me logged in on this device"
end
test "raises error for invalid token", %{conn: conn} do
conn = get(conn, ~p"/users/log-in/invalid-token")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Magic link is invalid or it has expired."
end
end
describe "POST /users/log-in - email and password" do
test "logs the user in", %{conn: conn, user: user} do
user = set_password(user)
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
user = set_password(user)
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_firehose_web_user_remember_me"]
assert redirected_to(conn) == ~p"/"
end
test "logs the user in with return to", %{conn: conn, user: user} do
user = set_password(user)
conn =
conn
|> init_test_session(user_return_to: "/foo/bar")
|> post(~p"/users/log-in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == "/foo/bar"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
end
test "emits error message with invalid credentials", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in?mode=password", %{
"user" => %{"email" => user.email, "password" => "invalid_password"}
})
response = html_response(conn, 200)
assert response =~ "Log in"
assert response =~ "Invalid email or password"
end
end
describe "POST /users/log-in - magic link" do
test "sends magic link email when user exists", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email}
})
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Firehose.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
end
test "logs the user in", %{conn: conn, user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user)
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"token" => token}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
end
test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
{token, _hashed_token} = generate_user_magic_link_token(user)
refute user.confirmed_at
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"token" => token},
"_action" => "confirmed"
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert Accounts.get_user!(user.id).confirmed_at
end
test "emits error message when magic link is invalid", %{conn: conn} do
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"token" => "invalid"}
})
assert html_response(conn, 200) =~ "The link is invalid or it has expired."
end
end
describe "DELETE /users/log-out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> delete(~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
end
end

View File

@ -0,0 +1,148 @@
defmodule FirehoseWeb.UserSettingsControllerTest do
use FirehoseWeb.ConnCase, async: true
alias Firehose.Accounts
import Firehose.AccountsFixtures
setup :register_and_log_in_user
describe "GET /users/settings" do
test "renders settings page", %{conn: conn} do
conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200)
assert response =~ "Settings"
end
test "redirects if user is not logged in" do
conn = build_conn()
conn = get(conn, ~p"/users/settings")
assert redirected_to(conn) == ~p"/users/log-in"
end
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
test "redirects if user is not in sudo mode", %{conn: conn} do
conn = get(conn, ~p"/users/settings")
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must re-authenticate to access this page."
end
end
describe "PUT /users/settings (change password form)" do
test "updates the user password and resets tokens", %{conn: conn, user: user} do
new_password_conn =
put(conn, ~p"/users/settings", %{
"action" => "update_password",
"user" => %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
})
assert redirected_to(new_password_conn) == ~p"/users/settings"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "does not update password on invalid data", %{conn: conn} do
old_password_conn =
put(conn, ~p"/users/settings", %{
"action" => "update_password",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
response = html_response(old_password_conn, 200)
assert response =~ "Settings"
assert response =~ "should be at least 12 character(s)"
assert response =~ "does not match password"
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
end
end
describe "PUT /users/settings (change email form)" do
@tag :capture_log
test "updates the user email", %{conn: conn, user: user} do
conn =
put(conn, ~p"/users/settings", %{
"action" => "update_email",
"user" => %{"email" => unique_user_email()}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"A link to confirm your email"
assert Accounts.get_user_by_email(user.email)
end
test "does not update email on invalid data", %{conn: conn} do
conn =
put(conn, ~p"/users/settings", %{
"action" => "update_email",
"user" => %{"email" => "with spaces"}
})
response = html_response(conn, 200)
assert response =~ "Settings"
assert response =~ "must have the @ sign and no spaces"
end
end
describe "GET /users/settings/confirm-email/:token" do
setup %{user: user} do
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{token: token, email: email}
end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"Email changed successfully"
refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(email)
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Email change link is invalid or it has expired"
end
test "does not update email with invalid token", %{conn: conn, user: user} do
conn = get(conn, ~p"/users/settings/confirm-email/oops")
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Email change link is invalid or it has expired"
assert Accounts.get_user_by_email(user.email)
end
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
assert redirected_to(conn) == ~p"/users/log-in"
end
end
end

View File

@ -0,0 +1,88 @@
defmodule FirehoseWeb.EditorDashboardLiveTest do
use FirehoseWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup do
posts = [
%Blogex.Post{
id: "live-post",
title: "Live Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A live post",
date: ~D[2020-01-01],
published: true,
blog: :test_blog,
tags: []
},
%Blogex.Post{
id: "draft-post",
title: "Draft Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A draft post",
date: ~D[2026-03-12],
published: false,
blog: :test_blog,
tags: []
},
%Blogex.Post{
id: "scheduled-post",
title: "Scheduled Post",
author: "Test Author",
body: "<p>Body</p>",
description: "A scheduled post",
date: ~D[2099-06-15],
published: true,
blog: :test_blog,
tags: ["future"]
}
]
{:ok, _} =
Firehose.Test.FakeBlog.start(posts,
blog_id: :test_blog,
title: "Test Blog",
base_path: "/blog/test"
)
Application.put_env(:blogex, :blogs, [Firehose.Test.FakeBlog])
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
:ok
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/editor/dashboard")
assert {:redirect, %{to: to}} = redirect
assert to =~ "/users/log-in"
end
end
describe "authenticated" do
setup :register_and_log_in_user
test "renders the dashboard", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Dashboard"
end
test "shows draft posts", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Draft Post"
end
test "shows scheduled posts with days until live", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
assert html =~ "Scheduled Post"
assert html =~ "days"
end
test "does not show live posts", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
refute html =~ "Live Post"
end
end
end

View File

@ -0,0 +1,293 @@
defmodule FirehoseWeb.UserAuthTest do
use FirehoseWeb.ConnCase, async: true
alias Firehose.Accounts
alias Firehose.Accounts.Scope
alias FirehoseWeb.UserAuth
import Firehose.AccountsFixtures
@remember_me_cookie "_firehose_web_user_remember_me"
@remember_me_cookie_max_age 60 * 60 * 24 * 14
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
%{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn}
end
describe "log_in_user/3" do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
assert Accounts.get_user_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "keeps session when re-authenticating", %{conn: conn, user: user} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> put_session(:to_be_removed, "value")
|> UserAuth.log_in_user(user)
assert get_session(conn, :to_be_removed)
end
test "clears session when user does not match when re-authenticating", %{
conn: conn,
user: user
} do
other_user = user_fixture()
conn =
conn
|> assign(:current_scope, Scope.for_user(other_user))
|> put_session(:to_be_removed, "value")
|> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, user: user} do
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :user_remember_me) == true
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == @remember_me_cookie_max_age
end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :user_remember_me) == true
conn =
conn
|> recycle()
|> Map.replace!(:secret_key_base, FirehoseWeb.Endpoint.config(:secret_key_base))
|> fetch_cookies()
|> init_test_session(%{user_remember_me: true})
# the conn is already logged in and has the remember_me cookie set,
# now we log in again and even without explicitly setting remember_me,
# the cookie should be set again
conn = conn |> UserAuth.log_in_user(user, %{})
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == @remember_me_cookie_max_age
assert get_session(conn, :user_remember_me) == true
end
end
describe "logout_user/1" do
test "erases session and cookies", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn
|> put_session(:user_token, user_token)
|> put_req_cookie(@remember_me_cookie, user_token)
|> fetch_cookies()
|> UserAuth.log_out_user()
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Accounts.get_user_by_session_token(user_token)
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_scope_for_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert get_session(conn, :user_token) == user_token
end
test "authenticates user from cookies", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
user_token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
conn =
conn
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert get_session(conn, :user_token) == user_token
assert get_session(conn, :user_remember_me)
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ = Accounts.generate_user_session_token(user)
conn = UserAuth.fetch_current_scope_for_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_scope
end
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
offset_user_token(token, -10, :day)
{user, _} = Accounts.get_user_by_session_token(token)
conn =
conn
|> put_session(:user_token, token)
|> put_session(:user_remember_me, true)
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert new_token = get_session(conn, :user_token)
assert new_token != token
assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert new_signed_token != signed_token
assert max_age == @remember_me_cookie_max_age
end
end
describe "require_sudo_mode/2" do
test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do
conn =
conn
|> fetch_flash()
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.require_sudo_mode([])
refute conn.halted
refute conn.status
end
test "redirects when authentication is too old", %{conn: conn, user: user} do
eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute)
user = %{user | authenticated_at: eleven_minutes_ago}
user_token = Accounts.generate_user_session_token(user)
{user, token_inserted_at} = Accounts.get_user_by_session_token(user_token)
assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt
conn =
conn
|> fetch_flash()
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.require_sudo_mode([])
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must re-authenticate to access this page."
end
end
describe "redirect_if_user_is_authenticated/2" do
setup %{conn: conn} do
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
end
test "redirects if user is authenticated", %{conn: conn, user: user} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.redirect_if_user_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
end
test "does not redirect if user is not authenticated", %{conn: conn} do
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
refute conn.halted
refute conn.status
end
end
describe "require_authenticated_user/2" do
setup %{conn: conn} do
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
end
test "redirects if user is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
assert conn.halted
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must log in to access this page."
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
end

View File

@ -35,4 +35,45 @@ defmodule FirehoseWeb.ConnCase do
Firehose.DataCase.setup_sandbox(tags) Firehose.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()} {:ok, conn: Phoenix.ConnTest.build_conn()}
end end
@doc """
Setup helper that registers and logs in users.
setup :register_and_log_in_user
It stores an updated connection and a registered user in the
test context.
"""
def register_and_log_in_user(%{conn: conn} = context) do
user = Firehose.AccountsFixtures.user_fixture()
scope = Firehose.Accounts.Scope.for_user(user)
opts =
context
|> Map.take([:token_authenticated_at])
|> Enum.into([])
%{conn: log_in_user(conn, user, opts), user: user, scope: scope}
end
@doc """
Logs the given `user` into the `conn`.
It returns an updated `conn`.
"""
def log_in_user(conn, user, opts \\ []) do
token = Firehose.Accounts.generate_user_session_token(user)
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
defp maybe_set_token_authenticated_at(_token, nil), do: nil
defp maybe_set_token_authenticated_at(token, authenticated_at) do
Firehose.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
end
end end

View File

@ -14,6 +14,8 @@ defmodule Firehose.DataCase do
this option is not recommended for other databases. this option is not recommended for other databases.
""" """
alias Ecto.Adapters.SQL.Sandbox
use ExUnit.CaseTemplate use ExUnit.CaseTemplate
using do using do
@ -36,8 +38,8 @@ defmodule Firehose.DataCase do
Sets up the sandbox based on the test tags. Sets up the sandbox based on the test tags.
""" """
def setup_sandbox(tags) do def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async]) pid = Sandbox.start_owner!(Firehose.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) on_exit(fn -> Sandbox.stop_owner(pid) end)
end end
@doc """ @doc """

View File

@ -0,0 +1,65 @@
defmodule Firehose.Test.FakeBlog do
@moduledoc """
A test double that implements the blog module interface,
backed by an Agent so tests can control the post data.
"""
use Agent
@defaults [
blog_id: :test_blog,
title: "Test Blog",
description: "A blog for tests",
base_path: "/blog/test"
]
def start(posts \\ [], opts \\ []) do
opts = Keyword.merge(@defaults, opts)
state = %{
posts: posts,
blog_id: opts[:blog_id],
title: opts[:title],
description: opts[:description],
base_path: opts[:base_path]
}
case Agent.start(fn -> state end, name: __MODULE__) do
{:ok, pid} ->
{:ok, pid}
{:error, {:already_started, pid}} ->
Agent.update(__MODULE__, fn _ -> state end)
{:ok, pid}
end
end
defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key))
def blog_id, do: get(:blog_id)
def title, do: get(:title)
def description, do: get(:description)
def base_path, do: get(:base_path)
def all_posts_unfiltered do
get(:posts)
|> Enum.sort_by(& &1.date, {:desc, Date})
end
def unfiltered_posts do
all_posts_unfiltered()
end
def all_posts do
get(:posts)
|> Enum.filter(& &1.published)
|> Enum.sort_by(& &1.date, {:desc, Date})
end
def all_tags do
all_posts()
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
end
end

View File

@ -0,0 +1,89 @@
defmodule Firehose.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Firehose.Accounts` context.
"""
import Ecto.Query
alias Firehose.Accounts
alias Firehose.Accounts.Scope
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
def valid_user_password, do: "hello world!"
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email()
})
end
def unconfirmed_user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> valid_user_attributes()
|> Accounts.register_user()
user
end
def user_fixture(attrs \\ %{}) do
user = unconfirmed_user_fixture(attrs)
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
{:ok, {user, _expired_tokens}} =
Accounts.login_user_by_magic_link(token)
user
end
def user_scope_fixture do
user = user_fixture()
user_scope_fixture(user)
end
def user_scope_fixture(user) do
Scope.for_user(user)
end
def set_password(user) do
{:ok, {user, _expired_tokens}} =
Accounts.update_user_password(user, %{password: valid_user_password()})
user
end
def extract_user_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
token
end
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
Firehose.Repo.update_all(
from(t in Accounts.UserToken,
where: t.token == ^token
),
set: [authenticated_at: authenticated_at]
)
end
def generate_user_magic_link_token(user) do
{encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login")
Firehose.Repo.insert!(user_token)
{encoded_token, user_token.token}
end
def offset_user_token(token, amount_to_add, unit) do
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)
Firehose.Repo.update_all(
from(ut in Accounts.UserToken, where: ut.token == ^token),
set: [inserted_at: dt, authenticated_at: dt]
)
end
end

View File

@ -110,9 +110,15 @@ defmodule Blogex do
* `Blogex.Router` mountable Plug router * `Blogex.Router` mountable Plug router
""" """
@doc "Returns true if draft posts should be visible (dev/test environments)."
def show_drafts? do
Application.get_env(:blogex, :show_drafts, false)
end
defdelegate blogs, to: Blogex.Registry defdelegate blogs, to: Blogex.Registry
defdelegate get_blog!(blog_id), to: Blogex.Registry defdelegate get_blog!(blog_id), to: Blogex.Registry
defdelegate get_blog(blog_id), to: Blogex.Registry defdelegate get_blog(blog_id), to: Blogex.Registry
defdelegate all_posts, to: Blogex.Registry defdelegate all_posts, to: Blogex.Registry
defdelegate all_posts_unfiltered, to: Blogex.Registry
defdelegate all_tags, to: Blogex.Registry defdelegate all_tags, to: Blogex.Registry
end end

View File

@ -73,14 +73,30 @@ defmodule Blogex.Blog do
@doc "Returns the base URL path for this blog." @doc "Returns the base URL path for this blog."
def base_path, do: @blog_base_path def base_path, do: @blog_base_path
@doc "Returns all published posts, newest first." @doc "Returns all compiled posts regardless of published status or date."
def all_posts, do: Enum.filter(@posts, & &1.published) def unfiltered_posts, do: @posts
@doc "Returns all visible posts, newest first. Drafts are included in dev/test."
def all_posts do
today = Date.utc_today()
if Blogex.show_drafts?() do
Enum.filter(@posts, &(not Date.after?(&1.date, today)))
else
Enum.filter(@posts, &(&1.published and not Date.after?(&1.date, today)))
end
end
@doc "Returns the N most recent published posts." @doc "Returns the N most recent published posts."
def recent_posts(n \\ 5), do: Enum.take(all_posts(), n) def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)
@doc "Returns all unique tags across all published posts." @doc "Returns all unique tags across all published posts."
def all_tags, do: @tags def all_tags do
all_posts()
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
end
@doc "Returns all published posts matching the given tag." @doc "Returns all published posts matching the given tag."
def posts_by_tag(tag) do def posts_by_tag(tag) do
@ -89,13 +105,13 @@ defmodule Blogex.Blog do
@doc "Returns a single post by slug/id, or raises." @doc "Returns a single post by slug/id, or raises."
def get_post!(id) do def get_post!(id) do
Enum.find(all_posts(), &(&1.id == id)) || Enum.find(unfiltered_posts(), &(&1.id == id)) ||
raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}" raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}"
end end
@doc "Returns a single post by slug/id, or nil." @doc "Returns a single post by slug/id, or nil."
def get_post(id) do def get_post(id) do
Enum.find(all_posts(), &(&1.id == id)) Enum.find(unfiltered_posts(), &(&1.id == id))
end end
@doc "Returns paginated posts. Page is 1-indexed." @doc "Returns paginated posts. Page is 1-indexed."

View File

@ -23,9 +23,11 @@ defmodule Blogex.Components do
* `:posts` - list of `%Blogex.Post{}` structs (required) * `:posts` - list of `%Blogex.Post{}` structs (required)
* `:base_path` - base URL path for post links (required) * `:base_path` - base URL path for post links (required)
* `:current_tag` - currently selected tag for highlighting (optional)
""" """
attr :posts, :list, required: true attr :posts, :list, required: true
attr :base_path, :string, required: true attr :base_path, :string, required: true
attr :current_tag, :string, default: nil
def post_index(assigns) do def post_index(assigns) do
~H""" ~H"""
@ -35,7 +37,7 @@ defmodule Blogex.Components do
<h2> <h2>
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a> <a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
</h2> </h2>
<.post_meta post={post} /> <.post_meta post={post} base_path={@base_path} current_tag={@current_tag} />
</header> </header>
<p class="blogex-post-description">{post.description}</p> <p class="blogex-post-description">{post.description}</p>
</article> </article>
@ -49,15 +51,17 @@ defmodule Blogex.Components do
## Attributes ## Attributes
* `:post` - a `%Blogex.Post{}` struct (required) * `:post` - a `%Blogex.Post{}` struct (required)
* `:base_path` - base URL path for tag links (required)
""" """
attr :post, :map, required: true attr :post, :map, required: true
attr :base_path, :string, required: true
def post_show(assigns) do def post_show(assigns) do
~H""" ~H"""
<article class="blogex-post"> <article class="blogex-post">
<header class="blogex-post-header"> <header class="blogex-post-header">
<h1>{@post.title}</h1> <h1>{@post.title}</h1>
<.post_meta post={@post} /> <.post_meta post={@post} base_path={@base_path} />
</header> </header>
<div class="blogex-post-body"> <div class="blogex-post-body">
{Phoenix.HTML.raw(@post.body)} {Phoenix.HTML.raw(@post.body)}
@ -68,8 +72,16 @@ defmodule Blogex.Components do
@doc """ @doc """
Renders post metadata (date, author, tags). Renders post metadata (date, author, tags).
## Attributes
* `:post` - a `%Blogex.Post{}` struct (required)
* `:base_path` - base URL path for tag links (required)
* `:current_tag` - currently selected tag for highlighting (optional)
""" """
attr :post, :map, required: true attr :post, :map, required: true
attr :base_path, :string, required: true
attr :current_tag, :string, default: nil
def post_meta(assigns) do def post_meta(assigns) do
~H""" ~H"""
@ -80,9 +92,13 @@ defmodule Blogex.Components do
<span :if={@post.author} class="blogex-post-author"> <span :if={@post.author} class="blogex-post-author">
by {@post.author} by {@post.author}
</span> </span>
<span :for={tag <- @post.tags} class="blogex-tag"> <a
:for={tag <- @post.tags}
href={"#{@base_path}/tag/#{tag}"}
class={["blogex-tag-link", tag == @current_tag && "blogex-tag-active"]}
>
{tag} {tag}
</span> </a>
</div> </div>
""" """
end end

View File

@ -64,7 +64,7 @@ defmodule Blogex.Layout do
</head> </head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;"> <body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav> <nav><a href={@base_path}>&larr; Back</a></nav>
<.post_show post={@post} /> <.post_show post={@post} base_path={@base_path} />
</body> </body>
</html> </html>
""" """

View File

@ -44,6 +44,23 @@ defmodule Blogex.Post do
published: boolean() published: boolean()
} }
@type visibility :: :draft | :scheduled | :live
@doc "Returns the visibility of a post: :draft, :scheduled, or :live."
def visibility(%__MODULE__{published: false}), do: :draft
def visibility(%__MODULE__{published: true, date: date}) do
if Date.after?(date, Date.utc_today()), do: :scheduled, else: :live
end
@doc "Returns days until a scheduled post goes live, or nil."
def days_until_live(%__MODULE__{} = post) do
case visibility(post) do
:scheduled -> Date.diff(post.date, Date.utc_today())
_ -> nil
end
end
@doc """ @doc """
Build callback for NimblePublisher. Build callback for NimblePublisher.

View File

@ -37,6 +37,13 @@ defmodule Blogex.Registry do
|> Enum.sort_by(& &1.date, {:desc, Date}) |> Enum.sort_by(& &1.date, {:desc, Date})
end end
@doc "Returns all posts from all blogs (unfiltered), sorted newest first."
def all_posts_unfiltered do
blogs()
|> Enum.flat_map(& &1.unfiltered_posts())
|> Enum.sort_by(& &1.date, {:desc, Date})
end
@doc "Returns all unique tags across all blogs." @doc "Returns all unique tags across all blogs."
def all_tags do def all_tags do
blogs() blogs()

22
blogex/mix.lock Normal file
View File

@ -0,0 +1,22 @@
%{
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_publisher": {:hex, :nimble_publisher, "1.1.1", "3ea4d4cfca45b11a5377bce7608367a9ddd7e717a9098161d8439eca23e239aa", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d67e15bddf07e8c60f75849008b78ea8c6b2b4ae8e3f882ccf0a22d57bd42ed0"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View File

@ -5,6 +5,8 @@
description: "Our testing strategy for 200+ LiveView modules" description: "Our testing strategy for 200+ LiveView modules"
} }
--- ---
*This is a sample blog post, generated to show what blogex can do.*
With over 200 LiveView modules in our codebase, we needed a testing strategy With over 200 LiveView modules in our codebase, we needed a testing strategy
that was both fast and reliable. Here's what we landed on. that was both fast and reliable. Here's what we landed on.

View File

@ -5,6 +5,8 @@
description: "How we replaced our Kafka consumer with Broadway for 10x throughput" description: "How we replaced our Kafka consumer with Broadway for 10x throughput"
} }
--- ---
*This is a sample blog post, generated to show what blogex can do.*
Last quarter we hit a wall with our homegrown Kafka consumer. Message lag was Last quarter we hit a wall with our homegrown Kafka consumer. Message lag was
growing, backpressure was non-existent, and our on-call engineers were losing growing, backpressure was non-existent, and our on-call engineers were losing
sleep. We decided to rebuild on [Broadway](https://github.com/dashbitco/broadway). sleep. We decided to rebuild on [Broadway](https://github.com/dashbitco/broadway).

View File

@ -5,6 +5,8 @@
description: "Reliable webhook delivery, dark mode, and improved search" description: "Reliable webhook delivery, dark mode, and improved search"
} }
--- ---
*This is a sample blog post, generated to show what blogex can do.*
Here's what landed in v2.3.0. Here's what landed in v2.3.0.
## Webhook Reliability ## Webhook Reliability

View File

@ -5,6 +5,8 @@
description: "New team dashboards, API rate limiting, and 12 bug fixes" description: "New team dashboards, API rate limiting, and 12 bug fixes"
} }
--- ---
*This is a sample blog post, generated to show what blogex can do.*
We're excited to ship v2.4.0 with two major features and a pile of bug fixes. We're excited to ship v2.4.0 with two major features and a pile of bug fixes.
## Team Dashboards ## Team Dashboards

View File

@ -15,6 +15,24 @@ defmodule Blogex.BlogTest do
assert "draft-post" not in ids assert "draft-post" not in ids
end end
test "excludes future-dated posts", %{blog: blog} do
ids = blog.all_posts() |> Enum.map(& &1.id)
refute "future-post" in ids
end
test "includes today-dated published posts" do
{:ok, _} = FakeBlog.start([
build(id: "today", date: Date.utc_today(), published: true),
build(id: "tomorrow", date: Date.add(Date.utc_today(), 1), published: true)
])
ids = FakeBlog.all_posts() |> Enum.map(& &1.id)
assert "today" in ids
refute "tomorrow" in ids
end
test "returns posts newest first", %{blog: blog} do test "returns posts newest first", %{blog: blog} do
dates = blog.all_posts() |> Enum.map(& &1.date) dates = blog.all_posts() |> Enum.map(& &1.date)
@ -23,6 +41,13 @@ defmodule Blogex.BlogTest do
end end
describe "recent_posts/1" do describe "recent_posts/1" do
test "excludes future-dated posts", %{blog: blog} do
posts = blog.recent_posts(100)
ids = Enum.map(posts, & &1.id)
refute "future-post" in ids
end
test "returns at most n posts", %{blog: blog} do test "returns at most n posts", %{blog: blog} do
assert length(blog.recent_posts(2)) == 2 assert length(blog.recent_posts(2)) == 2
end end
@ -35,6 +60,12 @@ defmodule Blogex.BlogTest do
end end
describe "posts_by_tag/1" do describe "posts_by_tag/1" do
test "excludes future-dated posts", %{blog: blog} do
posts = blog.posts_by_tag("future-only")
assert posts == []
end
test "returns only posts with the given tag", %{blog: blog} do test "returns only posts with the given tag", %{blog: blog} do
posts = blog.posts_by_tag("testing") posts = blog.posts_by_tag("testing")
@ -59,6 +90,10 @@ defmodule Blogex.BlogTest do
end end
describe "all_tags/0" do describe "all_tags/0" do
test "excludes tags only on future-dated posts", %{blog: blog} do
refute "future-only" in blog.all_tags()
end
test "returns unique sorted tags from published posts", %{blog: blog} do test "returns unique sorted tags from published posts", %{blog: blog} do
tags = blog.all_tags() tags = blog.all_tags()
@ -90,10 +125,14 @@ defmodule Blogex.BlogTest do
end end
end end
test "raises for draft post id", %{blog: blog} do test "returns a future-dated published post by slug", %{blog: blog} do
assert_raise Blogex.NotFoundError, fn -> post = blog.get_post!("future-post")
blog.get_post!("draft-post") assert post.id == "future-post"
end end
test "returns a draft post by slug", %{blog: blog} do
post = blog.get_post!("draft-post")
assert post.id == "draft-post"
end end
end end
@ -101,6 +140,14 @@ defmodule Blogex.BlogTest do
test "returns nil for unknown id", %{blog: blog} do test "returns nil for unknown id", %{blog: blog} do
assert blog.get_post("nope") == nil assert blog.get_post("nope") == nil
end end
test "returns a future-dated post", %{blog: blog} do
assert %{id: "future-post"} = blog.get_post("future-post")
end
test "returns a draft post", %{blog: blog} do
assert %{id: "draft-post"} = blog.get_post("draft-post")
end
end end
describe "paginate/2" do describe "paginate/2" do

Some files were not shown because too many files have changed in this diff Show More