Compare commits

..

5 Commits

5 changed files with 283 additions and 13 deletions
+189
View File
@@ -0,0 +1,189 @@
# Env-Var-Controlled Sign-Up Implementation Plan
**Goal:** Gate sign-up and the sign-in "Sign Up" link on the `PODSTALK_ALLOW_SIGNUP` environment variable instead of the number of existing users.
**Architecture:** Add an `AllowSignup bool` field to the `AuthHandler`. `main.go` reads `PODSTALK_ALLOW_SIGNUP` and sets it on the handler. The signup handler and the sign-in page link both consult this boolean. `store.UserCount()` stays as a general utility but is no longer used by authentication.
**Tech Stack:** Go (net/http), `strconv.ParseBool` for env parsing.
---
## File Structure
- Modify: `main.go` — read env var, set `AllowSignup` on the `AuthHandler`.
- Modify: `handler/auth.go` — replace count-based gating with the `AllowSignup` flag.
- No changes to `store/store.go` (`UserCount` is left in place).
- Modify: `template/signin.html` — render the "Sign Up" nav link with `{{if .AllowSignup}}`.
---
### Task 1: Add `AllowSignup` field and switch signup gating
**Files:**
- Modify: `handler/auth.go`
- [ ] **Step 1: Add the `AllowSignup` field to `AuthHandler` and replace the count-based redirect**
Replace:
```go
type AuthHandler struct {
Store *store.Store
Sessions *SessionStore
Tpl *Templates
}
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
count, err := h.Store.UserCount()
if err == nil && count > 0 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
```
with:
```go
type AuthHandler struct {
Store *store.Store
Sessions *SessionStore
Tpl *Templates
AllowSignup bool
}
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
if !h.AllowSignup {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
```
- [ ] **Step 2: Replace the `hasUsers()` helper with an `allowSignup()` helper**
Replace:
```go
func (h *AuthHandler) hasUsers() bool {
count, err := h.Store.UserCount()
return err == nil && count > 0
}
```
with:
```go
func (h *AuthHandler) allowSignup() bool {
return h.AllowSignup
}
```
- [ ] **Step 3: Update the three `AllowSignup` template renders to use `allowSignup()`**
Replace every occurrence of `"AllowSignup": h.allowSignup()` where it was passed as the `HasUser` key. In the three render calls in the sign-in handler, ensure the map key is `AllowSignup`:
```go
map[string]any{"AllowSignup": h.allowSignup()}
map[string]any{"Error": "Invalid email or password.", "AllowSignup": h.allowSignup()}
map[string]any{"Error": "Invalid email or password.", "AllowSignup": h.allowSignup()}
```
- [ ] **Step 3b: Update the sign-in template to use `AllowSignup` without inversion**
In `template/signin.html`, the nav-link conditional must render the link when sign-up is allowed. Replace `{{if not .HasUser}}` with `{{if .AllowSignup}}`. (Retain the `{{end}}`.)
- [ ] **Step 4: Commit**
```bash
git add handler/auth.go
git commit -m "Gate sign-up on AllowSignup field instead of user count"
```
---
### Task 2: Read the env var in `main.go` and wire it up
**Files:**
- Modify: `main.go`
- [ ] **Step 1: Add `strconv` to imports**
In `main.go`, add `"strconv"` to the import block (after `"os"`).
- [ ] **Step 2: Parse `PODSTALK_ALLOW_SIGNUP` and set it on the handler**
After the existing `baseURL` block, add:
```go
allowSignup := false
if v := os.Getenv("PODSTALK_ALLOW_SIGNUP"); v != "" {
allowSignup = envBool(v)
}
```
Change the `authH` literal to include the flag:
```go
authH := &handler.AuthHandler{Store: st, Sessions: sessions, Tpl: tpl, AllowSignup: allowSignup}
```
At the bottom (near `envOrDefault`), add the `envBool` helper:
```go
// envBool interprets a string as a truthy boolean.
func envBool(v string) bool {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "yes", "on":
return true
}
return false
}
```
Add `"strings"` to the imports if not already present (it is not present in `main.go`; add it).
- [ ] **Step 3: Commit**
```bash
git add main.go
git commit -m "Read PODSTALK_ALLOW_SIGNUP env var to control sign-up"
```
---
### Task 3: Verify
- [ ] **Step 1: Run the build script per AGENTS.md**
Run: `./scripts/build`
Expected: PASS (gofmt, go vet, go build, docker build all succeed).
- [ ] **Step 2: Manual sanity check (sandbox permitting)**
Run: `PODSTALK_ALLOW_SIGNUP=true go run . &` then `curl -s -o /dev/null -w "%{http_code}\n" -X GET localhost:8080/signup` → expect a 200 render.
Run: `PODSTALK_ALLOW_SIGNUP=false go run . &` then `curl -s -o /dev/null -w "%{http_code}\n" -X GET localhost:8080/signup` → expect 303 redirect to `/signin`.
- [ ] **Step 3: Final commit (if the build produced no fixes) and push**
```bash
git status
git push
```
---
## Self-Review
**1. Spec coverage:**
- Env var `PODSTALK_ALLOW_SIGNUP` parsed in `main.go` → Task 2 Step 2. ✓
- Default (unset) = disabled → Task 2 Step 2 (`allowSignup := false`, only overridden when set non-empty). ✓
- Truthy values (`true`, `1`, `yes`, `on`) enabled → `ParseBool` covers `1`/`true`, the switch covers `yes`/`on`. ✓
- Signup handler redirects when disabled → Task 1 Step 1. ✓
- Sign-in "Sign Up" link driven by same flag → Task 1 Steps 23 (`HasUser`). ✓
- `store.UserCount()` unchanged → noted in File Structure. ✓
**2. Placeholder scan:** No TBD/TODO; every task has concrete code. ✓
**3. Type consistency:** `AllowSignup` field, `allowSignup()` method, and `envBool` helper are named consistently throughout. ✓
@@ -0,0 +1,63 @@
# Allow Sign-Up Via Environment Variable
Date: 2026-08-19
Status: Draft
## Summary
Replace the current "count of users" check that gates sign-up with a
single environment variable. This lets an operator explicitly control
whether public sign-up is available, instead of Podstalk inferring it
from how many users already exist.
## Motivation
Today `ServeSignup` disables sign-up once a user already exists
(`store.UserCount() > 0`). That is implicit and easy to get wrong: the
first user must register before anyone can log in, and once that happens
no one can ever register again. Controlling this with an explicit
environment variable is predictable and self-documenting.
## Behavior
New environment variable: `PODSTALK_ALLOW_SIGNUP`.
- Unset or any falsy value (`false`, `0`, `no`, `off`): sign-up **disabled**.
- Truthy value (`true`, `1`, `yes`, `on`): sign-up **enabled**.
Default (unset) is **disabled**.
## Changes
### `main.go`
Read `PODSTALK_ALLOW_SIGNUP` from the environment and resolve it with a
truthy-value parser. Set the resulting boolean on the `AuthHandler`.
### `handler/auth.go`
- Add an `AllowSignup bool` field to `AuthHandler`.
- `ServeSignup`: redirect to `/signin` when `!AllowSignup` (replaces the
`UserCount() > 0` check).
- Replace the `hasUsers()` helper (driven by user count) with a check on
`AllowSignup`, so the Sign In page shows/ hides the "Sign Up" nav link
based on whether sign-up is enabled. The sign-in template passes
`AllowSignup` and renders the link with `{{if .AllowSignup}}`.
### Unchanged
`store.UserCount()` remains in the store package. It is no longer used by
authentication but is left in place as a general utility.
## Out of Scope
- No admin/user-management UI to invite specific users.
- No change to the existing `adduser` CLI command (operator provisioning
still works regardless of the sign-up toggle).
- No change to sign-in/sign-out behavior.
## Testing
- `scripts/build` passes (gofmt, go vet, go build, docker build).
- Manual verification is limited by the sandbox; behavior relies on the
straightforward boolean branch and the truthy parser.
+10 -11
View File
@@ -48,14 +48,14 @@ func (ss *SessionStore) Delete(token string) {
// --- Handlers ---
type AuthHandler struct {
Store *store.Store
Sessions *SessionStore
Tpl *Templates
Store *store.Store
Sessions *SessionStore
Tpl *Templates
AllowSignup bool
}
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
count, err := h.Store.UserCount()
if err == nil && count > 0 {
if !h.AllowSignup {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
@@ -97,14 +97,13 @@ func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
}
}
func (h *AuthHandler) hasUsers() bool {
count, err := h.Store.UserCount()
return err == nil && count > 0
func (h *AuthHandler) allowSignup() bool {
return h.AllowSignup
}
func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.Tpl.Render(w, "signin", map[string]any{"HasUser": h.hasUsers()})
h.Tpl.Render(w, "signin", map[string]any{"AllowSignup": h.allowSignup()})
return
}
@@ -114,12 +113,12 @@ func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
user, err := h.Store.UserByEmail(email)
if err != nil {
h.Tpl.Render(w, "signin", map[string]any{"Error": "Invalid email or password.", "HasUser": h.hasUsers()})
h.Tpl.Render(w, "signin", map[string]any{"Error": "Invalid email or password.", "AllowSignup": h.allowSignup()})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
h.Tpl.Render(w, "signin", map[string]any{"Error": "Invalid email or password.", "HasUser": h.hasUsers()})
h.Tpl.Render(w, "signin", map[string]any{"Error": "Invalid email or password.", "AllowSignup": h.allowSignup()})
return
}
+20 -1
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"os"
"runtime/debug"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
@@ -42,6 +44,11 @@ func main() {
baseURL = "http://localhost:8080"
}
allowSignup := false
if v := os.Getenv("PODSTALK_ALLOW_SIGNUP"); v != "" {
allowSignup = envBool(v)
}
st, err := store.New(dbPath)
if err != nil {
log.Fatalf("failed to open database: %v", err)
@@ -58,7 +65,7 @@ func main() {
authMid := &handler.AuthMiddleware{Store: st, Sessions: sessions}
authH := &handler.AuthHandler{Store: st, Sessions: sessions, Tpl: tpl}
authH := &handler.AuthHandler{Store: st, Sessions: sessions, Tpl: tpl, AllowSignup: allowSignup}
epH := &handler.EpisodeHandler{
Store: st,
Tpl: tpl,
@@ -157,3 +164,15 @@ func envOrDefault(key, def string) string {
}
return def
}
// envBool interprets a string as a truthy boolean.
func envBool(v string) bool {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "yes", "on":
return true
}
return false
}
+1 -1
View File
@@ -11,7 +11,7 @@
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
<ul class="nb-navbar-nav" role="menubar">
{{if not .HasUser}}
{{if .AllowSignup}}
<li class="nb-navbar-item" role="none">
<a href="/signup" class="nb-navbar-link" role="menuitem">Sign Up</a>
</li>