Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4d552b3f4 | |||
| d9b9876875 | |||
| 92677e8d5c | |||
| 8bca60dca5 | |||
| 3376b00717 |
@@ -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 2–3 (`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.
|
||||||
+7
-8
@@ -51,11 +51,11 @@ type AuthHandler struct {
|
|||||||
Store *store.Store
|
Store *store.Store
|
||||||
Sessions *SessionStore
|
Sessions *SessionStore
|
||||||
Tpl *Templates
|
Tpl *Templates
|
||||||
|
AllowSignup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
count, err := h.Store.UserCount()
|
if !h.AllowSignup {
|
||||||
if err == nil && count > 0 {
|
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,14 +97,13 @@ func (h *AuthHandler) ServeSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) hasUsers() bool {
|
func (h *AuthHandler) allowSignup() bool {
|
||||||
count, err := h.Store.UserCount()
|
return h.AllowSignup
|
||||||
return err == nil && count > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodGet {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +113,12 @@ func (h *AuthHandler) ServeSignin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
user, err := h.Store.UserByEmail(email)
|
user, err := h.Store.UserByEmail(email)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
@@ -42,6 +44,11 @@ func main() {
|
|||||||
baseURL = "http://localhost:8080"
|
baseURL = "http://localhost:8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowSignup := false
|
||||||
|
if v := os.Getenv("PODSTALK_ALLOW_SIGNUP"); v != "" {
|
||||||
|
allowSignup = envBool(v)
|
||||||
|
}
|
||||||
|
|
||||||
st, err := store.New(dbPath)
|
st, err := store.New(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to open database: %v", err)
|
log.Fatalf("failed to open database: %v", err)
|
||||||
@@ -58,7 +65,7 @@ func main() {
|
|||||||
|
|
||||||
authMid := &handler.AuthMiddleware{Store: st, Sessions: sessions}
|
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{
|
epH := &handler.EpisodeHandler{
|
||||||
Store: st,
|
Store: st,
|
||||||
Tpl: tpl,
|
Tpl: tpl,
|
||||||
@@ -157,3 +164,15 @@ func envOrDefault(key, def string) string {
|
|||||||
}
|
}
|
||||||
return def
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
||||||
<ul class="nb-navbar-nav" role="menubar">
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
{{if not .HasUser}}
|
{{if .AllowSignup}}
|
||||||
<li class="nb-navbar-item" role="none">
|
<li class="nb-navbar-item" role="none">
|
||||||
<a href="/signup" class="nb-navbar-link" role="menuitem">Sign Up</a>
|
<a href="/signup" class="nb-navbar-link" role="menuitem">Sign Up</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user