Implements tag/category system backend following microformats2 p-category specification. Database changes: - Migration 008: Add tags and note_tags tables - Normalized tag storage (case-insensitive lookup, display name preserved) - Indexes for performance New module: - starpunk/tags.py: Tag management functions - normalize_tag: Normalize tag strings - get_or_create_tag: Get or create tag records - add_tags_to_note: Associate tags with notes (replaces existing) - get_note_tags: Retrieve note tags (alphabetically ordered) - get_tag_by_name: Lookup tag by normalized name - get_notes_by_tag: Get all notes with specific tag - parse_tag_input: Parse comma-separated tag input Model updates: - Note.tags property (lazy-loaded, prefer pre-loading in routes) - Note.to_dict() add include_tags parameter CRUD updates: - create_note() accepts tags parameter - update_note() accepts tags parameter (None = no change, [] = remove all) Micropub integration: - Pass tags to create_note() (tags already extracted by extract_tags()) - Return tags in q=source response Per design doc: docs/design/v1.3.0/microformats-tags-design.md Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
18 KiB
Auth Redirect Loop - Visual Diagram
Current Behavior (BROKEN)
┌──────────────────────────────────────────────────────────────────┐
│ User clicks "Dev Login" │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ GET /dev/login │
│ │
│ 1. create_dev_session(me) → returns "abc123xyz" │
│ 2. response.set_cookie("session", "abc123xyz") │
│ 3. flash("DEV MODE: Logged in") ← This triggers Flask session! │
│ Flask writes: session = {_flashes: ["message"]} │
│ 4. return redirect("/admin/") │
│ │
│ ⚠️ Cookie "session" is now Flask session data, NOT auth token! │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Browser → GET /admin/ │
│ Cookie: session={_flashes: ["message"]} ← WRONG DATA! │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ @require_auth decorator │
│ │
│ 1. session_token = request.cookies.get("session") │
│ → Gets: {_flashes: ["message"]} ← Not a token! │
│ 2. verify_session("{_flashes: ...}") │
│ → hash("{_flashes: ...}") doesn't match any DB session │
│ → Returns None │
│ 3. if not session_info: │
│ session["next"] = request.url ← More Flask session! │
│ return redirect("/admin/login") │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Browser → GET /admin/login │
│ User sees: Login page (NOT dashboard) │
│ Result: REDIRECT LOOP ❌ │
└──────────────────────────────────────────────────────────────────┘
Fixed Behavior (CORRECT)
┌──────────────────────────────────────────────────────────────────┐
│ User clicks "Dev Login" │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ GET /dev/login │
│ │
│ 1. create_dev_session(me) → returns "abc123xyz" │
│ 2. response.set_cookie("starpunk_session", "abc123xyz") │
│ 3. flash("DEV MODE: Logged in") │
│ Flask writes: session = {_flashes: ["message"]} │
│ 4. return redirect("/admin/") │
│ │
│ ✅ Two separate cookies: │
│ - starpunk_session = "abc123xyz" (auth token) │
│ - session = {_flashes: ["message"]} (Flask session) │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Browser → GET /admin/ │
│ Cookie: starpunk_session=abc123xyz │
│ Cookie: session={_flashes: ["message"]} │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ @require_auth decorator │
│ │
│ 1. session_token = request.cookies.get("starpunk_session") │
│ → Gets: "abc123xyz" ✅ Correct auth token! │
│ 2. verify_session("abc123xyz") │
│ → hash("abc123xyz") matches DB session │
│ → Returns: {me: "https://dev.example.com", ...} │
│ 3. if session_info: ✅ Valid session! │
│ g.user = session_info │
│ g.me = session_info["me"] │
│ return dashboard() ← Continues to dashboard! │
└──────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Browser → Renders /admin/ dashboard │
│ User sees: Dashboard with notes ✅ │
│ Flash message: "DEV MODE: Logged in" ✅ │
│ Result: SUCCESS! No redirect loop! ✅ │
└──────────────────────────────────────────────────────────────────┘
Cookie Collision Visualization
BEFORE (Broken)
┌─────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ Cookies for localhost:5000: │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Name: session │ │
│ │ Value: {_flashes: ["message"]} │ │
│ │ │ │
│ │ ❌ CONFLICT: This should be auth token!│ │
│ │ Flask overwrote our auth token! │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
AFTER (Fixed)
┌─────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ Cookies for localhost:5000: │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Name: starpunk_session │ │
│ │ Value: abc123xyz... │ │
│ │ Purpose: Auth token ✅ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Name: session │ │
│ │ Value: {_flashes: ["message"]} │ │
│ │ Purpose: Flask session ✅ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ✅ Two separate cookies, no conflict! │
└─────────────────────────────────────────────────┘
Timeline of Events
Broken Flow
Time Request Cookie State Auth State
────────────────────────────────────────────────────────────────────────
T0 GET /dev/login (none) Not authed
T1 ↓ set_cookie session = "abc123xyz" Token set ✅
T2 ↓ flash() session = {_flashes: [...]} OVERWRITTEN ❌
T3 302 → /admin/ session = {_flashes: [...]} Token LOST ❌
T4 GET /admin/ session = {_flashes: [...]} Not authed ❌
T5 ↓ @require_auth verify("{_flashes...}") = None FAIL ❌
T6 302 → /admin/login session = {_flashes: [...]} Not authed ❌
T7 GET /admin/login session = {_flashes: [...]} Not authed ❌
→ User sees login page (LOOP!) ❌
Fixed Flow
Time Request Cookie State Auth State
─────────────────────────────────────────────────────────────────────────────
T0 GET /dev/login (none) Not authed
T1 ↓ set_cookie starpunk_session = "abc123xyz" Token set ✅
T2 ↓ flash() session = {_flashes: [...]} Flask data ✅
starpunk_session = "abc123xyz" Token preserved ✅
T3 302 → /admin/ starpunk_session = "abc123xyz" Authed ✅
session = {_flashes: [...]}
T4 GET /admin/ starpunk_session = "abc123xyz" Authed ✅
T5 ↓ @require_auth verify("abc123xyz") = {me: ...} SUCCESS ✅
T6 Render dashboard starpunk_session = "abc123xyz" Authed ✅
→ User sees dashboard ✅
Request/Response Detail
Broken Request/Response Cycle
REQUEST 1: GET /dev/login
═══════════════════════════════════════════════════════════════════
RESPONSE 1:
HTTP/1.1 302 Found
Location: /admin/
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
❌ Flask overwrote our auth token!
───────────────────────────────────────────────────────────────────
REQUEST 2: GET /admin/
Cookie: session={_flashes: [...]}
^^^^^^^^^^^^^^^^^^^^^^^^
❌ Sending Flask session data, not auth token!
═══════════════════════════════════════════════════════════════════
RESPONSE 2:
HTTP/1.1 302 Found
Location: /admin/login
❌ @require_auth rejected (no valid token)
Fixed Request/Response Cycle
REQUEST 1: GET /dev/login
═══════════════════════════════════════════════════════════════════
RESPONSE 1:
HTTP/1.1 302 Found
Location: /admin/
Set-Cookie: starpunk_session=abc123xyz; HttpOnly; SameSite=Lax
✅ Auth token in separate cookie
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
✅ Flask session in separate cookie
───────────────────────────────────────────────────────────────────
REQUEST 2: GET /admin/
Cookie: starpunk_session=abc123xyz
✅ Sending correct auth token!
Cookie: session={_flashes: [...]}
✅ Flask session data also sent (for flash messages)
═══════════════════════════════════════════════════════════════════
RESPONSE 2:
HTTP/1.1 200 OK
Content-Type: text/html
<html>
<!-- Dashboard renders successfully! ✅ -->
</html>
Code Comparison
Setting the Cookie
# BEFORE (Broken)
response.set_cookie(
"session", # ❌ Conflicts with Flask
session_token,
httponly=True,
secure=False,
samesite="Lax",
)
# AFTER (Fixed)
response.set_cookie(
"starpunk_session", # ✅ No conflict!
session_token,
httponly=True,
secure=False,
samesite="Lax",
)
Reading the Cookie
# BEFORE (Broken)
session_token = request.cookies.get("session")
# Gets Flask session data, not our token! ❌
# AFTER (Fixed)
session_token = request.cookies.get("starpunk_session")
# Gets our auth token correctly! ✅
Why Flash Triggers the Problem
Flask's flash() function writes to the session:
# When you call:
flash("DEV MODE: Logged in", "warning")
# Flask internally does:
session['_flashes'] = [("warning", "DEV MODE: Logged in")]
# Which triggers:
response.set_cookie("session", serialize(session), ...)
# This OVERWRITES any cookie named "session"!
The Key Insight
Flask owns the session cookie name. We cannot use it.
Flask reserves this cookie for its own session management (flash messages, session["key"] storage, etc.). When we try to use the same cookie name for our auth token, Flask overwrites it whenever session data is modified.
Solution: Use our own namespace → starpunk_session
Architectural Principle Established
Cookie Naming Convention: All application cookies MUST use an application-specific prefix to avoid conflicts with framework-reserved names.
- Framework cookies:
session,csrf_token, etc. (owned by Flask) - Application cookies:
starpunk_session,starpunk_*(owned by StarPunk)
This separation ensures:
- No name collisions
- Clear ownership
- Easier debugging (you know which cookie is which)
- Standards compliance