fase-2-release-tracking #1

Merged
kengru merged 34 commits from fase-2-release-tracking into main 2026-05-16 03:43:16 +00:00
Owner
No description provided.
kengru self-assigned this 2026-05-16 03:42:24 +00:00
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation para el digest semanal: signer + verifier sin storage, link
de baja válido 90 días firmado con UNSUBSCRIBE_SECRET. POST /unsubscribe
acepta token en form (browser) o query (One-Click), siempre idempotente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Product emails (digest, eventualmente otras notificaciones) requieren
plain+HTML alternativo y headers como List-Unsubscribe. Auth emails siguen
usando Send plain — sin cambios visibles para ellos. Headers reservados
(Subject/From/Content-Type) no son sobreescribibles vía ExtraHeaders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sender compartido entre el worker semanal y el endpoint admin de prueba.
Query usa last_digest_sent_at como cutoff; primera vez aplica lookback de
7 días para no spamear con histórico. Headers List-Unsubscribe firmados
con el signer de unsubscribe (RFC 8058 one-click). Plantillas plain+HTML
embebidas via web/emails/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Worker tickea cada hora, dispara los domingos 9am UTC sobre todos los
usuarios opt-in (email_digest_enabled=1, deleted_at IS NULL); el Sender
ya garantiza idempotencia. POST /admin/trigger-digest acepta user_id o
email y bearer auth con ADMIN_TOKEN — feature off si la env var está
vacía. Pequeño helper de testing en email.Client para inyectar fake SMTP
en tests de paquetes consumidores. ADR 0007 anota la separación
auth-emails plain / product-emails multipart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Export al vuelo del perfil + biblioteca + cursor de lectura del user
logueado. Incluye source_url y notes (campos privados del dueño, ocultos
en /u/{id}). format_version para que herramientas downstream detecten
shape. Link a la descarga en /settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flow:
  · POST /settings/delete-account/request manda código 6-dígitos
    (tabla deletion_codes, vence 15 min, single-use).
  · POST /settings/delete-account/confirm valida y marca users.deleted_at +
    destruye sesiones del user.
  · El middleware pendingDeletionRedirect manda a /restore-account a cualquier
    request autenticada con deleted_at != NULL (paths exentos: logout, static,
    unsubscribe).
  · /restore-account permite cancelar (deleted_at = NULL) o forzar hard delete
    inmediato.
  · CleanupWorker (tick 24h) borra hard a los 7 días, inserta hash one-way
    en deletion_log (DELETION_LOG_PEPPER env var) y limpia deletion_codes
    viejos. Hard delete usa transacción + CASCADE para library_entries,
    sessions, deletion_codes; otp_codes se limpia explícito (sin FK).

Migration 0006 agrega deletion_codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot del repo refleja la fase 2 completa: nuevos paquetes (admin,
mangadex, releases/{handler,matcher,worker,digest}, unsubscribe), env
vars requeridas en prod (UNSUBSCRIBE_SECRET, DELETION_LOG_PEPPER) y
optional (ADMIN_TOKEN), capacidades operativas nuevas (workers en
proceso, multipart emails, GDPR compliance). Backlog reordenado: lo
postergado de fase 2 (widget en home, rate limit en /unsubscribe, tests
en CD) queda arriba.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cuando el user hace triple-click en el código formateado del email
(ej. "123 456"), copia con espacios + whitespace al borde y a veces
non-breaking spaces (U+00A0) que Gmail/Apple Mail inyectan. El maxlength
del input cortaba el paste antes de llegar al server. Solución sin JS:

  · server-side sanitiza con sanitizeCode/sanitizeDeletionCode que
    extrae solo dígitos del input (cubre NBSP, tabs, separadores varios)
  · quitar maxlength del input en verify.html y delete-account.html
    para que el browser no trunque el paste — el server filtra después

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Header global con "mokuji" → / y, si hay sesión, links a biblioteca,
releases y settings. Resuelve el "no hay cómo volver al home" + "no hay
botón a /releases". Para páginas anónimas (/auth) muestra solo el logo
porque .Session es nil; los structs startData/verifyData ahora incluyen
el campo Session *session.Session (siempre nil ahí) para que el template
no falle al evaluar {{if .Session}}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cada entry ahora muestra un badge según m.mangadex_status:

  · matched + N caps nuevos    → "N nuevos" (link a /releases)
  · matched + 0 nuevos         → "al día"
  · needs_review               → "matching incierto"
  · no_results                 → "sin source"
  · unmatched (no procesado)   → "buscando…"

UnreadCount viene de un subselect correlated sobre chapter filtrando por
preferred_language del user y respetando el cursor (last_read_chapter_id).
Sin indexes adicionales — idx_chapter_manga_published ya cubre.

Quito los links a /settings y /u/{id} del footer de library porque ahora
están en el nav del header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /library/entry/{id}: header con cover + status + counters + lista
de caps cronológica.

  · sort=desc (default) | asc — toggle "más nuevos / más viejos primero"
  · include_read=1 — checkbox para mostrar caps leídos (default oculta)
  · cada cap tiene dos acciones:
      "leer →"     POST /library/entry/{id}/read   → marca leído + 303
                   al external_url del cap; form con target="_blank" para
                   abrir el lector en pestaña nueva.
      "marcar"     POST /library/entry/{id}/mark-read → marca + vuelve.

Autorización en cascada: loadEntry filtra por user_id (404 si no es del
user logueado); markChapterRead exige que chapter.manga_id == entry.manga_id
en el mismo UPDATE (rows-affected=0 si no matchea).

El badge "N nuevos" / "al día" de /library ahora linkea a esta detail
en vez de al feed global. El nav /releases sigue como dashboard
cronológico cross-manga.

mangadex-badge se movió a library/_partials.html para reusarlo desde
list.html y entry.html sin parsearlo dos veces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
go:embed silenciosamente excluye archivos cuyo nombre empieza con "_" o
".". library/_partials.html funcionaba con os.DirFS en los tests pero
fallaba al arrancar el binario real con embed.FS. Renombrado a
partials.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El badge "buscando…" / "needs_review" / "no_results" no eran clickables,
único camino al detail era el badge "matched" — si el manga no estaba
matcheado, no había cómo entrar.

Solución:
  · el <strong>title</strong> del manga ahora está envuelto en un <a>
    al detail (browsers manejan correctamente <a> dentro de <summary>:
    click en el link navega, no toggle el details).
  · todos los estados del badge son ahora links al detail, no sólo
    los "matched".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dos problemas reportados por el user al agregar mangas nuevos:

1. Los caps tardaban hasta una hora en aparecer porque el worker tickea
   cada 1h y el manga recién matcheado tenía que esperar al siguiente
   tick para hacer el primer FetchFeed.

2. El badge mostraba "al día" incluso cuando no había ningún capítulo
   en la DB para ese manga, porque UnreadCount=0 cubría tanto "todo
   leído" como "todavía no halamos nada".

Fix 1 — Poller sincrónico:
  · worker.PollOne(mangaID, mangadexID) ahora es público
  · library.Config tiene un Poller opcional (interface mínima)
  · tryMatchManga, después de updateMangadexMatch exitoso con status
    matched, dispara PollOne sincrónicamente. Tarda ~1-2s extra al
    POST /library/add pero el manga ya tiene caps al volver a /library.
  · errores del poller (rate limit, network) se loguean como warn; el
    match persiste y el worker periódico retoma porque next_check_at
    queda NULL hasta que el poll termine.
  · main.go construye el worker antes que el library handler para
    poder pasarlo como Poller.

Fix 2 — Badge "pendiente":
  · Entry tiene un TotalChapters nuevo (caps en idioma preferido del
    user) además de UnreadCount.
  · partials.html distingue:
      matched + total=0      → "pendiente" (gris)
      matched + unread>0     → "N nuevos" (azul)
      matched + total>0, unread=0 → "al día" (verde)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El form tenía target="_blank" y POST a /library/entry/{id}/read; el server
respondía 303 + Location a la URL externa del cap (mangadex.org/...).
Resultado en el browser: pestaña nueva en blanco.

Causa: el CSP del server tiene `form-action 'self'` por defensa contra
forms con destino malicioso. La spec dice que la directiva aplica a TODOS
los URLs del redirect chain de un form submission — incluido el destino
final. El browser bloquea el 303 cross-origin silenciosamente, sin
mensaje en consola visible al user.

Fix: en lugar de http.Redirect, el endpoint responde 200 con HTML
mínimo que tiene <meta http-equiv="refresh" content="0; url=EXTERNAL">
+ fallback <a> manual. La navegación cross-origin se hace por meta
refresh (no es form-action chain), así que pasa la CSP. Mismo resultado
visible para el user: la nueva pestaña abre el lector. URL escapada
con html/template para defensa contra externals con caracteres raros.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
includeEmptyPages=0 en /manga/{id}/feed excluía los capítulos publicados
en plataformas externas (MangaPlus de Shueisha, ComiKey, etc). MangaDex
solo mantiene el meta + externalUrl de esos caps — los marca como
"empty pages" porque no tiene las imágenes. Para mangas oficiales con
licencia internacional (One Piece, One Punch Man, Spy x Family, etc.)
TODOS los caps son externos, así que el filtro los borraba todos:

  total: 0 returned: 0

Sin el filtro:

  total: 6 returned: 6  (1180, 1181, 1182, etc. con externalUrl=mangaplus)

El cliente ya guarda externalUrl en chapter.external_url, y entry.html
usa esa URL en el botón "leer →", así que sólo había que dejar de
filtrar. Bug detectado debuggeando por qué One Piece quedaba con 0 caps
después de matchear OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El header del entry mostraba "0 capítulos" cuando todos los caps estaban
leídos y el filtro "ver leídos" no estaba activo, porque el handler usaba
len(chapters) (slice filtrado) en lugar del total real del manga. Ahora
usa entry.TotalChapters que viene del query (count de todos los caps en
el idioma preferido del user, sin filtrar por cursor).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El digest cambia el ReaderURL de cada cap: en vez de apuntar directo al
lector externo, ahora apunta a /r/{token} en mokuji. El handler valida el
token, marca leído el cap (con check de ownership por SQL join: cap →
library_entries activo del user que lo firmó → user no soft-deleted), y
responde con HTML meta-refresh al external_url. Resultado: un click en el
email = "leí esto + abrir lector" sin pasar por la app.

Componentes:
  · unsubscribe.Signer ya era genérico; agregamos scope ScopeReadChapter
    + SignReadChapter / VerifyReadChapter (payload userID:chapterID:scope:
    issuedAt, 30d validity).
  · internal/library/emailread.go monta GET /r/{token} si library.Config
    tiene un Signer cableado; sin signer, el endpoint no existe.
  · digest.Sender ya tenía signer; agregamos shortcutURL(userID, chapterID)
    y sobrescribimos Item.ReaderURL antes de renderear.
  · main.go construye el signer arriba (antes de library.New) y lo reusa
    para el handler /unsubscribe.

Tokens son self-contained — no requieren sesión. La cadena SQL del handler
asegura ownership: si alguien hace replay con un token ajeno o el user fue
borrado, devuelve 410 con mensaje neutral.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
· borrar var _ = email.Message{} y el import "email" del delete.go:
    no eran necesarios — h.email es un campo (tipo de profile.go), el
    archivo delete.go no nombra el package "email" directamente.
  · borrar itoa local, usar strconv.Itoa (ya hay strconv import).
  · acortar dev unsubscribe secret a un literal honesto ("no-usar-en-
    produccion" sin mentir sobre longitud).
  · LIMIT 500 en cleanupWorker.tickOnce — defensa por si la cohorte
    de hard-delete se acumuló por estar caído.
  · borrar comentarios narrativos en main.go sobre el orden de las
    construcciones (el orden ya está impuesto por el código).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auth y profile/delete tenían el mismo flow duplicado:
generateCode/hashCode/isValidCode/sanitizeCode + Format. Ahora un solo
paquete internal/code expone Generate, Hash, Valid, Sanitize, Format
(6 dígitos, sha256, sanitize tolerante a NBSP). auth y profile usan el
paquete; las constantes locales solo guardan la duración (10min login,
15min delete).

Renombro variables locales que sombreaban el paquete (code → otp).
Borro tests duplicados en auth_test.go (los del paquete code los cubren).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El campo era stringly-typed con valores libres documentados en el comment.
matcher.Status (type Status string + constantes StatusMatched, ...) ya
existía en internal/releases/matcher. html/template eq sigue funcionando
porque el subyacente es string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pendingDeletionRedirect corría SELECT deleted_at FROM users en CADA
request autenticada (90%+ del traffic). Para un caso raro (<0.1% de
users en grace), eso es 1 round-trip extra siempre.

Fix: Session.Load ya hacía JOIN implicit con SELECT de sessions; agrego
JOIN explícito con users + s.PendingDeletion bool en la struct. El
middleware ahora solo lee el flag. Las sesiones se destruyen en
deleteAccountConfirm si el user se borra a sí mismo, así que el flag
nunca es stale para una sesión activa.

Tests de session ahora aplican todas las migraciones (el JOIN requiere
users.deleted_at, columna de la 0004).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
El patrón r = r.WithContext(session.NewContext(context.Background(),
&session.Session{UserID: uid})) aparecía 20+ veces en tests. Nuevo helper:
session.WithUserID(r, uid). Migra los tests que solo necesitaban UserID
(no CSRFToken u otros campos).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tests adicionales para gaps de Fase 2
All checks were successful
Deploy mokuji / deploy (push) Successful in 9s
e87fea3b59
· session.Load cachea deleted_at en PendingDeletion: test que verifica
    el comportamiento antes y después del soft-delete.
  · worker.PollOne (público desde el fix de poll inmediato): test que
    bypasea la query "due" + propaga ErrRateLimited reagendando.
  · listEntries calcula TotalChapters vs UnreadCount con cursor parcial:
    test directo de los counts (sin pasar por render del badge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kengru merged commit e87fea3b59 into main 2026-05-16 03:43:16 +00:00
kengru deleted branch fase-2-release-tracking 2026-05-16 03:43:16 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kengru/mokuji!1
No description provided.