Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
Show user list in admin dashboard
package main
import (
"context"
"database/sql"
_ "embed"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed schema.sql
var schema string
var errNoDBRows = sql.ErrNoRows
type DB struct {
db *sql.DB
}
func openDB(filename string) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
db := &DB{sqlDB}
if err := db.init(context.TODO()); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (db *DB) init(ctx context.Context) error {
var n int
if err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_schema").Scan(&n); err != nil {
return err
} else if n != 0 {
return nil
}
if _, err := db.db.ExecContext(ctx, schema); err != nil {
return err
}
// TODO: drop this
defaultUser := User{Username: "root"}
if err := defaultUser.SetPassword("root"); err != nil {
return err
}
return db.StoreUser(ctx, &defaultUser)
}
func (db *DB) Close() error {
return db.db.Close()
}
func (db *DB) FetchUser(ctx context.Context, id ID[*User]) (*User, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM User WHERE id = ?", id)
if err != nil {
return nil, err
}
var user User
err = scanRow(&user, rows)
return &user, err
}
func (db *DB) FetchUserByUsername(ctx context.Context, username string) (*User, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM User WHERE username = ?", username)
if err != nil {
return nil, err
}
var user User
err = scanRow(&user, rows)
return &user, err
}
func (db *DB) StoreUser(ctx context.Context, user *User) error {
return db.db.QueryRowContext(ctx, `
INSERT INTO User(id, username, password_hash)
VALUES (:id, :username, :password_hash)
ON CONFLICT(id) DO UPDATE SET
username = :username,
password_hash = :password_hash
RETURNING id
`, entityArgs(user)...).Scan(&user.ID)
}
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM User")
if err != nil {
return nil, err
}
defer rows.Close()
var l []User
for rows.Next() {
var user User
if err := scan(&user, rows); err != nil {
return nil, err
}
l = append(l, user)
}
return l, rows.Close()
}
func (db *DB) FetchClient(ctx context.Context, id ID[*Client]) (*Client, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE id = ?", id)
if err != nil {
return nil, err
}
var client Client
err = scanRow(&client, rows)
return &client, err
}
func (db *DB) FetchClientByClientID(ctx context.Context, clientID string) (*Client, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE client_id = ?", clientID)
if err != nil {
return nil, err
}
var client Client
err = scanRow(&client, rows)
return &client, err
}
func (db *DB) StoreClient(ctx context.Context, client *Client) error {
return db.db.QueryRowContext(ctx, `
INSERT INTO Client(id, client_id, client_secret_hash, owner,
redirect_uris, client_name, client_uri)
VALUES (:id, :client_id, :client_secret_hash, :owner,
:redirect_uris, :client_name, :client_uri)
ON CONFLICT(id) DO UPDATE SET
client_id = :client_id,
client_secret_hash = :client_secret_hash,
owner = :owner,
redirect_uris = :redirect_uris,
client_name = :client_name,
client_uri = :client_uri
RETURNING id
`, entityArgs(client)...).Scan(&client.ID)
}
func (db *DB) ListClients(ctx context.Context, owner ID[*User]) ([]Client, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE owner IS ?", owner)
if err != nil {
return nil, err
}
defer rows.Close()
var l []Client
for rows.Next() {
var client Client
if err := scan(&client, rows); err != nil {
return nil, err
}
l = append(l, client)
}
return l, rows.Close()
}
func (db *DB) DeleteClient(ctx context.Context, id ID[*Client]) error {
_, err := db.db.ExecContext(ctx, "DELETE FROM Client WHERE id = ?", id)
return err
}
func (db *DB) FetchAccessToken(ctx context.Context, id ID[*AccessToken]) (*AccessToken, error) {
rows, err := db.db.QueryContext(ctx, "SELECT * FROM AccessToken WHERE id = ?", id)
if err != nil {
return nil, err
}
var token AccessToken
err = scanRow(&token, rows)
return &token, err
}
func (db *DB) CreateAccessToken(ctx context.Context, token *AccessToken) error {
return db.db.QueryRowContext(ctx, `
INSERT INTO AccessToken(hash, user, client, scope, issued_at, expires_at)
VALUES (:hash, :user, :client, :scope, :issued_at, :expires_at)
RETURNING id
`, entityArgs(token)...).Scan(&token.ID)
}
func (db *DB) CreateAuthCode(ctx context.Context, code *AuthCode) error {
return db.db.QueryRowContext(ctx, `
INSERT INTO AuthCode(hash, created_at, user, client, scope)
VALUES (:hash, :created_at, :user, :client, :scope)
RETURNING id
`, entityArgs(code)...).Scan(&code.ID)
}
func (db *DB) PopAuthCode(ctx context.Context, id ID[*AuthCode]) (*AuthCode, error) {
rows, err := db.db.QueryContext(ctx, `
DELETE FROM AuthCode
WHERE id = ?
RETURNING *
`, id)
if err != nil {
return nil, err
}
var authCode AuthCode
err = scanRow(&authCode, rows)
return &authCode, err
}
func (db *DB) Maintain(ctx context.Context) error {
_, err := db.db.ExecContext(ctx, `
DELETE FROM AccessToken
WHERE timediff('now', expires_at) > 0
`)
if err != nil {
return err
}
_, err = db.db.ExecContext(ctx, `
DELETE FROM AuthCode
WHERE timediff(?, created_at) > 0
`, time.Now().Add(-authCodeExpiration))
if err != nil {
return err
}
return nil
}
func scan(e entity, rows *sql.Rows) error {
columns := e.columns()
keys, err := rows.Columns()
if err != nil {
panic(err)
}
out := make([]interface{}, len(keys))
for i, k := range keys {
v, ok := columns[k]
if !ok {
panic(fmt.Errorf("unknown column %q", k))
}
out[i] = v
}
return rows.Scan(out...)
}
func scanRow(e entity, rows *sql.Rows) error {
if !rows.Next() {
return sql.ErrNoRows
}
if err := scan(e, rows); err != nil {
return err
}
return rows.Close()
}
func entityArgs(e entity) []interface{} {
columns := e.columns()
l := make([]interface{}, 0, len(columns))
for k, v := range columns {
l = append(l, sql.Named(k, v))
}
return l
}
{{ template "head.html" }}
<main>
<h1>sinwon</h1>
<p>Welcome, {{ .Me.Username }}!</p>
<form method="post">
{{ if .Me.Admin }}
<a href="/user/new"><button type="button">Create user</button></a>
<a href="/client/new"><button type="button">Register new client</button></a>
{{ end }}
<a href="/user/{{ .Me.ID }}"><button type="button">Settings</button></a>
<button type="submit" formaction="/logout">Logout</button>
</form>
{{ if .Me.Admin }}
<h2>Clients</h2>
{{ with .Clients }}
<p>{{ . | len }} clients registered:</p>
<p> <a href="/client/new"><button type="button">Register new client</button></a> </p>
<table>
<tr>
<th>Client ID</th>
<th>Name</th>
</tr>
{{ range . }}
<tr>
<td><a href="/client/{{ .ID }}"><code>{{ .ClientID }}</code></a></td>
<td>{{ .ClientName }}</td>
</tr>
{{ end }}
</table>
{{ else }}
<p>No client registered yet.</p>
{{ end }}
<h2>Users</h2>
<p>
<a href="/user/new"><button type="button">Create user</button></a>
</p>
<table>
<tr>
<th>Username</th>
<th>Role</th>
</tr>
{{ range .Users }}
<tr>
<td><a href="/user/{{ .ID }}">{{ .Username }}</a></td>
<td>
{{ if .Admin }}
Administrator
{{ else }}
Regular user
{{ end}}
</td>
</tr>
{{ end }}
</table>
{{ end }}
</main>
{{ template "foot.html" }}
package main
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
func index(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
db := dbFromContext(ctx)
tpl := templateFromContext(ctx)
loginToken := loginTokenFromContext(ctx)
if loginToken == nil {
http.Redirect(w, req, "/login", http.StatusFound)
return
}
me, err := db.FetchUser(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
}
clients, err := db.ListClients(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
}
var users []User
if me.Admin {
users, err = db.ListUsers(ctx)
if err != nil {
httpError(w, err)
return
}
}
data := struct {
Clients []Client
Me *User
Clients []Client Users []User
}{
Me: me,
Clients: clients,
Me: me,
Users: users,
}
if err := tpl.ExecuteTemplate(w, "index.html", &data); err != nil {
panic(err)
}
}
func login(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
db := dbFromContext(ctx)
tpl := templateFromContext(ctx)
q := req.URL.Query()
rawRedirectURI := q.Get("redirect_uri")
if rawRedirectURI == "" {
rawRedirectURI = "/"
}
redirectURI, err := url.Parse(rawRedirectURI)
if err != nil || redirectURI.Scheme != "" || redirectURI.Opaque != "" || redirectURI.User != nil || redirectURI.Host != "" {
http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
return
}
if loginTokenFromContext(ctx) != nil {
http.Redirect(w, req, redirectURI.String(), http.StatusFound)
return
}
username := req.PostFormValue("username")
password := req.PostFormValue("password")
if username == "" {
if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
panic(err)
}
return
}
user, err := db.FetchUserByUsername(ctx, username)
if err != nil && err != errNoDBRows {
httpError(w, fmt.Errorf("failed to fetch user: %v", err))
return
}
if err == nil {
err = user.VerifyPassword(password)
}
if err != nil {
log.Printf("login failed for user %q: %v", username, err)
// TODO: show error message
if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
panic(err)
}
return
}
if user.PasswordNeedsRehash() {
if err := user.SetPassword(password); err != nil {
httpError(w, fmt.Errorf("failed to rehash password: %v", err))
return
}
if err := db.StoreUser(ctx, user); err != nil {
httpError(w, fmt.Errorf("failed to store user: %v", err))
return
}
}
token := AccessToken{
User: user.ID,
Scope: internalTokenScope,
}
secret, err := token.Generate()
if err != nil {
httpError(w, fmt.Errorf("failed to generate access token: %v", err))
return
}
if err := db.CreateAccessToken(ctx, &token); err != nil {
httpError(w, fmt.Errorf("failed to create access token: %v", err))
return
}
setLoginTokenCookie(w, req, &token, secret)
http.Redirect(w, req, redirectURI.String(), http.StatusFound)
}
func logout(w http.ResponseWriter, req *http.Request) {
unsetLoginTokenCookie(w, req)
http.Redirect(w, req, "/login", http.StatusFound)
}
func manageUser(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
db := dbFromContext(ctx)
tpl := templateFromContext(ctx)
user := new(User)
if idStr := chi.URLParam(req, "id"); idStr != "" {
id, err := ParseID[*User](idStr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err = db.FetchUser(ctx, id)
if err != nil {
httpError(w, err)
return
}
}
loginToken := loginTokenFromContext(ctx)
if loginToken == nil {
http.Redirect(w, req, "/login", http.StatusFound)
return
}
me, err := db.FetchUser(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
} else if loginToken.User != user.ID && !me.Admin {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
username := req.PostFormValue("username")
password := req.PostFormValue("password")
admin := req.PostFormValue("admin") == "on"
if username == "" {
data := struct {
User *User
Me *User
}{
User: user,
Me: me,
}
if err := tpl.ExecuteTemplate(w, "manage-user.html", &data); err != nil {
panic(err)
}
return
}
user.Username = username
if me.Admin && user.ID != me.ID {
user.Admin = admin
}
if password != "" {
if err := user.SetPassword(password); err != nil {
httpError(w, err)
return
}
}
if err := db.StoreUser(ctx, user); err != nil {
httpError(w, err)
return
}
http.Redirect(w, req, "/", http.StatusFound)
}