Compare commits
46 Commits
e2caed41b9
...
b2a4cdab42
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a4cdab42 | ||
|
|
b6ff541b13 | ||
|
|
88ec475a5b | ||
|
|
74a1201b88 | ||
|
|
8d40e09e90 | ||
|
|
2591796d82 | ||
|
|
f1560ff0e7 | ||
|
|
5395b2de80 | ||
|
|
86f7ffbe94 | ||
|
|
df20c478f4 | ||
|
|
20f12847d6 | ||
|
|
370275f7b5 | ||
|
|
a380d0cb69 | ||
|
|
0577ceced0 | ||
|
|
037e9f86ff | ||
|
|
17a0f2709c | ||
|
|
cf7df3111f | ||
|
|
04a736765d | ||
|
|
590dd4a265 | ||
|
|
fddbb4e777 | ||
|
|
b3cdd93de8 | ||
|
|
87e6490f85 | ||
|
|
afc763d9d9 | ||
|
|
2708f81f1d | ||
|
|
73e0d9cf1e | ||
|
|
505a2d0bd6 | ||
|
|
9426582abc | ||
|
|
c18f9cd2e3 | ||
|
|
5d49af2790 | ||
|
|
671add15bb | ||
|
|
be4be118a3 | ||
|
|
afdf557174 | ||
|
|
506c72b2d8 | ||
|
|
2d94bbde62 | ||
|
|
c9901691e5 | ||
|
|
a5c26f24ab | ||
| 3837a72059 | |||
| 9be7c1774b | |||
|
|
a5dee5c21e | ||
|
|
9e6252e1e7 | ||
|
|
6f2beb8bb8 | ||
|
|
24847ca7fd | ||
|
|
e0e5acb322 | ||
|
|
9bad5d3770 | ||
|
|
f563d3c26a | ||
|
|
e780d6b6e5 |
44
.beads/.gitignore
vendored
Normal 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
@ -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
@ -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
|
||||||
0
.beads/interactions.jsonl
Normal file
12
.beads/issues.jsonl
Normal 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
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"database": "beads.db",
|
||||||
|
"jsonl_export": "issues.jsonl"
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
30
.devcontainer/docker-compose.yml
Normal 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
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
# Dokku setup (may contain secrets)
|
||||||
|
dokku-setup.sh
|
||||||
|
/output/
|
||||||
BIN
.nono.sh.swp
Normal file
148
.pi/skills/test-writer/SKILL.md
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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`.
|
||||||
|
#
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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
@ -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
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
297
app/lib/firehose/accounts.ex
Normal 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
|
||||||
33
app/lib/firehose/accounts/scope.ex
Normal 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
|
||||||
132
app/lib/firehose/accounts/user.ex
Normal 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
|
||||||
84
app/lib/firehose/accounts/user_notifier.ex
Normal 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
|
||||||
156
app/lib/firehose/accounts/user_token.ex
Normal 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
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
31
app/lib/firehose/release.ex
Normal 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
|
||||||
@ -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())
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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">← Back to posts</a>
|
<a href={@base_path} class="text-sm text-primary hover:underline">← 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>
|
||||||
|
|||||||
@ -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">← All posts</a>
|
<a href={@base_path} class="text-sm text-primary hover:underline">← All posts</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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)}>
|
||||||
|
|||||||
@ -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
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
defmodule FirehoseWeb.UserRegistrationHTML do
|
||||||
|
use FirehoseWeb, :html
|
||||||
|
|
||||||
|
embed_templates "user_registration_html/*"
|
||||||
|
end
|
||||||
@ -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>
|
||||||
88
app/lib/firehose_web/controllers/user_session_controller.ex
Normal 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
|
||||||
9
app/lib/firehose_web/controllers/user_session_html.ex
Normal 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
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
77
app/lib/firehose_web/controllers/user_settings_controller.ex
Normal 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
|
||||||
5
app/lib/firehose_web/controllers/user_settings_html.ex
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
defmodule FirehoseWeb.UserSettingsHTML do
|
||||||
|
use FirehoseWeb, :html
|
||||||
|
|
||||||
|
embed_templates "user_settings_html/*"
|
||||||
|
end
|
||||||
@ -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>
|
||||||
117
app/lib/firehose_web/live/editor_dashboard_live.ex
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
256
app/lib/firehose_web/user_auth.ex
Normal 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
|
||||||
49
app/lib_dev/firehose/checks/no_conn_shadowing.ex
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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"},
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
12
app/priv/blog/engineering/2026/03-20-llm-simple-play.md
Normal 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).
|
||||||
46
app/priv/blog/engineering/2026/03-24-blog-triage.md
Normal 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.
|
||||||
8
app/priv/blog/engineering/2099/01-01-future-test-post.md
Normal 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.
|
||||||
@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Draft posts** (unpublished) show an amber banner:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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**
|
||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 27 KiB |
6
app/rel/overlays/bin/migrate
Executable 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
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd -P -- "$(dirname -- "$0")"/..
|
||||||
|
|
||||||
|
PHX_SERVER=true exec ./bin/firehose start
|
||||||
397
app/test/firehose/accounts_test.exs
Normal 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
|
||||||
73
app/test/firehose_web/controllers/blog_controller_test.exs
Normal 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
|
||||||
123
app/test/firehose_web/controllers/blog_tags_test.exs
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
88
app/test/firehose_web/live/editor_dashboard_live_test.exs
Normal 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
|
||||||
293
app/test/firehose_web/user_auth_test.exs
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 """
|
||||||
|
|||||||
65
app/test/support/fake_blog.ex
Normal 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
|
||||||
89
app/test/support/fixtures/accounts_fixtures.ex
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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."
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}>← Back</a></nav>
|
<nav><a href={@base_path}>← Back</a></nav>
|
||||||
<.post_show post={@post} />
|
<.post_show post={@post} base_path={@base_path} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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"},
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||