oficina / memoria Public

A malformed address says so; Memoria capitalised (review of the door)

The door's form leaves validation to the server, so "paul@" or an empty
field reached the owner check and was told a link had been sent. A shape
check now answers ?err=address first — the same for everyone, looking at
the text only, so it reveals nothing about owners. The header and page
titles say Memoria, as the door does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e547f21cbf1295a95c6d1e18051095c547cd44ab parent acb1f92
10 files changed, +45 −9
  • internal/memoria/auth.go +20 −0
  • internal/memoria/auth_test.go +16 −0
  • internal/memoria/templates/add.html +1 −1
  • internal/memoria/templates/connectors.html +1 −1
  • internal/memoria/templates/credits.html +1 −1
  • internal/memoria/templates/document.html +1 −1
  • internal/memoria/templates/index.html +1 −1
  • internal/memoria/templates/layout.html +2 −2
  • internal/memoria/templates/notion.html +1 −1
  • internal/memoria/templates/signin.html +1 −1

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.624s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.865s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go
index 6b4b162..57c1154 100644
--- a/internal/memoria/auth.go
+++ b/internal/memoria/auth.go
@@ -117,6 +117,14 @@ func (a *app) beginGuard(origin string, next http.HandlerFunc) http.HandlerFunc
// differently from the thing it guards is not guarding it. There
// is no error branch now, because FormValue has no error to
// branch on — the two calls cannot disagree.
+ // A shape check first, answered the same for everyone: the door's
+ // form leaves validation to the server, and "paul@" or an empty
+ // field should say so rather than claim a link was sent. It looks
+ // only at the text, never at who owns what, so it tells nothing.
+ if !plausibleAddress(r.FormValue("address")) {
+ http.Redirect(w, r, signinPath+"?err=address", http.StatusSeeOther)
+ return
+ }
if !a.isOwner(normaliseAddress(r.FormValue("address"))) {
// No address in the log line, ever: §3.2, gate 4.
a.logger.Info("sign-in refused before classifier dial")
@@ -268,3 +276,15 @@ type oauthPage struct {
Data connect.OAuthData
Door chrome.SignInNotice
}
+
+// plausibleAddress is the shape of an email address and nothing more:
+// something, one @, a domain with a dot, no spaces.
+func plausibleAddress(v string) bool {
+ v = strings.TrimSpace(v)
+ at := strings.LastIndexByte(v, '@')
+ if at <= 0 || at == len(v)-1 || strings.ContainsAny(v, " \t\r\n") || strings.Count(v, "@") != 1 {
+ return false
+ }
+ domain := v[at+1:]
+ return strings.Contains(domain, ".") && !strings.HasPrefix(domain, ".") && !strings.HasSuffix(domain, ".")
+}
diff --git a/internal/memoria/auth_test.go b/internal/memoria/auth_test.go
new file mode 100644
index 0000000..101568f
--- /dev/null
+++ b/internal/memoria/auth_test.go
@@ -0,0 +1,16 @@
+package memoria
+
+import "testing"
+
+func TestPlausibleAddressIsShapeOnly(t *testing.T) {
+ for v, want := range map[string]bool{
+ "paul@example.com": true, " paul@example.com ": true, "a@b.co": true,
+ "": false, "paul@": false, "@example.com": false, "paul": false,
+ "paul@example": false, "paul@.com": false, "paul@example.": false,
+ "pa ul@example.com": false, "a@b@c.com": false,
+ } {
+ if got := plausibleAddress(v); got != want {
+ t.Errorf("plausibleAddress(%q) = %v, want %v", v, got, want)
+ }
+ }
+}
diff --git a/internal/memoria/templates/add.html b/internal/memoria/templates/add.html
index 7af1689..2c5439e 100644
--- a/internal/memoria/templates/add.html
+++ b/internal/memoria/templates/add.html
@@ -1,4 +1,4 @@
-{{define "title"}}Add content — memoria{{end}}
+{{define "title"}}Add content — Memoria{{end}}
{{define "content"}}
{{/* Adding content, on its own page (2026-09-29, Paul: "remove the Add
forms from the index … that should be a separate Add content form").
diff --git a/internal/memoria/templates/connectors.html b/internal/memoria/templates/connectors.html
index 83e1060..0872f10 100644
--- a/internal/memoria/templates/connectors.html
+++ b/internal/memoria/templates/connectors.html
@@ -1,4 +1,4 @@
-{{define "title"}}Sources · memoria{{end}}
+{{define "title"}}Sources · Memoria{{end}}
{{define "content"}}
<h1>Sources</h1>
diff --git a/internal/memoria/templates/credits.html b/internal/memoria/templates/credits.html
index 9f026c3..c9d1e6d 100644
--- a/internal/memoria/templates/credits.html
+++ b/internal/memoria/templates/credits.html
@@ -1,4 +1,4 @@
-{{define "title"}}Open source · memoria{{end}}
+{{define "title"}}Open source · Memoria{{end}}
{{define "head"}}<link rel="stylesheet" href="/static/credits.css"><style>[rst-brand] { min-block-size: 24px; display: inline-flex; align-items: center; }</style>{{end}}
{{define "content"}}{{template "estilo/credits" .Data}}{{end}}
diff --git a/internal/memoria/templates/document.html b/internal/memoria/templates/document.html
index 50538e9..e80a57e 100644
--- a/internal/memoria/templates/document.html
+++ b/internal/memoria/templates/document.html
@@ -1,4 +1,4 @@
-{{define "title"}}{{.Title}} — memoria{{end}}
+{{define "title"}}{{.Title}} — Memoria{{end}}
{{define "content"}}
<nav rst-breadcrumb><a href="/">← Back to search</a></nav>
<h1>{{.Title}}</h1>
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index efd4d7f..02d824a 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -1,4 +1,4 @@
-{{define "title"}}memoria{{end}}
+{{define "title"}}Memoria{{end}}
{{define "content"}}
<form method="get" action="/" rst-search>
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index 9dafaa4..6d3b6d3 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{block "title" .}}memoria{{end}}</title>
+<title>{{block "title" .}}Memoria{{end}}</title>
<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
@@ -34,7 +34,7 @@
</head>
<body>
<header rst-page-header>
- <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">memoria</a>
+ <a href="/" rst-brand estilo-brand><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Memoria</a>
{{if .Signed}}<nav rst-inline>
<details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details>
{{if .HomeEnabled}}<a href="/account/home" rst-btn="ghost">Connect Home account</a>{{end}}
diff --git a/internal/memoria/templates/notion.html b/internal/memoria/templates/notion.html
index 83e8ae7..46c5278 100644
--- a/internal/memoria/templates/notion.html
+++ b/internal/memoria/templates/notion.html
@@ -1,4 +1,4 @@
-{{define "title"}}Notion · memoria{{end}}
+{{define "title"}}Notion · Memoria{{end}}
{{/* The status page refreshes itself ONLY while a run is active, and it
is a meta refresh because every affordance here works scriptless.
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html
index 242ac1c..c4d1151 100644
--- a/internal/memoria/templates/signin.html
+++ b/internal/memoria/templates/signin.html
@@ -1,4 +1,4 @@
-{{define "title"}}Sign in — memoria{{end}}
+{{define "title"}}Sign in — Memoria{{end}}
{{/* The suite's door (estilo chrome): one card on the team's sky, the
app's mark and name at its head. chrome.css draws the card, sky.css
the sky; both are vendored from estilo (go run ./scripts/writeestilo).