repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

2.5 KB · 86 lines · Go Raw History
 1package main
 2
 3import (
 4	"database/sql"
 5	"errors"
 6	"fmt"
 7	"strings"
 8	"time"
 9)
10
11// The account the ntfy container was created with by edge/setup-ntfy.sh. It is
12// seeded as the username too, and the two must not be assumed equal after that:
13// ntfy has no rename, so changing the username here can never change the
14// account over there, and a code path that compares the strings instead of
15// reading this row breaks delivery at the moment somebody is trying to log in.
16const seedUsername = "isaac"
17
18var errNoUser = errors.New("this installation has not been initialized")
19
20type User struct {
21	Username    string
22	NtfyAccount string
23	Created     time.Time
24}
25
26// loadUser reads the single row. Absent means `make auth-init` has not run.
27func loadUser(db *sql.DB) (User, error) {
28	var (
29		u       User
30		created int64
31	)
32	err := db.QueryRow(
33		`SELECT username, ntfy_account, created FROM users WHERE id = 1`,
34	).Scan(&u.Username, &u.NtfyAccount, &created)
35	if errors.Is(err, sql.ErrNoRows) {
36		return User{}, errNoUser
37	}
38	if err != nil {
39		return User{}, err
40	}
41	u.Created = time.Unix(created, 0).UTC()
42	return u, nil
43}
44
45// createUser seeds the one row, reporting false if it was already there. The
46// caller prints nothing in that case rather than handing back a set of recovery
47// codes that were never applied.
48func createUser(db *sql.DB, username string) (bool, error) {
49	res, err := db.Exec(`
50        INSERT OR IGNORE INTO users (id, username, ntfy_account, created)
51        VALUES (1, ?, ?, ?)`,
52		username, seedUsername, time.Now().Unix())
53	if err != nil {
54		return false, err
55	}
56	n, err := res.RowsAffected()
57	return n > 0, err
58}
59
60// usernameOK keeps the field to something that can be typed on a phone and
61// cannot be confused with an address or a path.
62func usernameOK(name string) error {
63	if len(name) < 2 || len(name) > 32 {
64		return fmt.Errorf("a username is between 2 and 32 characters")
65	}
66	for _, r := range name {
67		switch {
68		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
69		default:
70			return fmt.Errorf("a username holds lowercase letters, digits, dashes and underscores")
71		}
72	}
73	return nil
74}
75
76func setUsername(db *sql.DB, name string) error {
77	name = strings.ToLower(strings.TrimSpace(name))
78	if err := usernameOK(name); err != nil {
79		return err
80	}
81	// ntfy_account is left alone. Renaming there is a delete and recreate that
82	// loses the access control entries and every token with them.
83	_, err := db.Exec(`UPDATE users SET username = ? WHERE id = 1`, name)
84	return err
85}