orchard
mirrorEvery 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
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}