Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken rækkefølge man vil have dem i, er en kendsgerning om personen. En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige forlod menuen. To ting, prøverne fandt undervejs, og begge var mine: Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det. Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet var en menuflytning. Migration 0019. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
537 lines
19 KiB
Go
537 lines
19 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("store: not found")
|
|
ErrEmailInUse = errors.New("store: that email address already has an account")
|
|
ErrInviteInvalid = errors.New("store: invite is not valid")
|
|
)
|
|
|
|
type User struct {
|
|
ID string
|
|
Email string
|
|
Name string
|
|
PasswordHash string
|
|
TOTPSecret string
|
|
TOTPEnabled bool
|
|
AvatarColor string
|
|
Timezone string
|
|
Locale string
|
|
IsAdmin bool
|
|
// SidebarCollapsed is the sidebar headings this person has folded away, by
|
|
// key. On the account rather than in localStorage, for the same reason a
|
|
// project group carries `collapsed` on its row: folding a heading is a
|
|
// statement about the work — "I am not in Etiketter at the moment" — and that
|
|
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
|
// opposite case and is stored per browser.
|
|
SidebarCollapsed []string
|
|
|
|
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
|
// means the order the program ships with. Unknown keys are ignored and missing
|
|
// ones are appended, so adding a view later is not a migration.
|
|
NavOrder []string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
|
// with stray whitespace and inconsistent case from every direction — a login form,
|
|
// an invite, an import — and two spellings of one address must never become two
|
|
// accounts.
|
|
//
|
|
// Only case and surrounding space are touched. Gmail's dots-and-plus rules are
|
|
// deliberately not applied: they are Gmail's, not the internet's, and stripping a
|
|
// "+todo" suffix would break the very addressing people use to route mail here.
|
|
func NormalizeEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
|
//
|
|
// The Inbox is created here rather than lazily on first use because every other
|
|
// part of the app is entitled to assume it exists — a task with no project has
|
|
// nowhere to go, and "create it if missing" scattered across handlers is how two
|
|
// of them end up racing to create the second one.
|
|
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
|
u.Email = NormalizeEmail(u.Email)
|
|
if u.ID == "" {
|
|
u.ID = NewID()
|
|
}
|
|
if u.AvatarColor == "" {
|
|
u.AvatarColor = avatarColorFor(u.Email)
|
|
}
|
|
now := time.Now().UTC()
|
|
u.CreatedAt, u.UpdatedAt = now, now
|
|
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx,
|
|
`INSERT INTO users (id, email, name, password_hash, totp_secret, totp_enabled,
|
|
avatar_color, timezone, locale, is_admin, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?)`,
|
|
u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor,
|
|
orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"),
|
|
boolToInt(u.IsAdmin), now.Unix(), now.Unix())
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return ErrEmailInUse
|
|
}
|
|
return fmt.Errorf("insert user: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at)
|
|
VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`,
|
|
NewID(), inboxName, u.ID, now.Unix(), now.Unix())
|
|
if err != nil {
|
|
return fmt.Errorf("create inbox: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (db *DB) UserByEmail(ctx context.Context, email string) (*User, error) {
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email))
|
|
}
|
|
|
|
func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id)
|
|
}
|
|
|
|
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
|
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
|
|
|
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
|
var u User
|
|
var secret sql.NullString
|
|
var totpEnabled, isAdmin int
|
|
var created, updated int64
|
|
|
|
var collapsed, navOrder string
|
|
|
|
err := db.QueryRowContext(ctx, query, args...).Scan(
|
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
|
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
u.TOTPSecret = secret.String
|
|
u.TOTPEnabled = totpEnabled == 1
|
|
u.IsAdmin = isAdmin == 1
|
|
// A malformed value is read as "nothing folded" rather than as an error: the
|
|
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
|
u.SidebarCollapsed = []string{}
|
|
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
|
u.NavOrder = []string{}
|
|
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
|
u.CreatedAt = time.Unix(created, 0).UTC()
|
|
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
|
return &u, nil
|
|
}
|
|
|
|
// SetNavOrder records the order of the fixed views.
|
|
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
|
|
if order == nil {
|
|
order = []string{}
|
|
}
|
|
raw, err := json.Marshal(order)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
|
|
string(raw), time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetSidebarCollapsed records which sidebar headings are folded.
|
|
//
|
|
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
|
// a save button and validates three fields together, and this is a toggle that
|
|
// writes on every click. Sharing the path would mean a fold sending a name and a
|
|
// timezone it was never asked to change.
|
|
func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections []string) error {
|
|
if sections == nil {
|
|
sections = []string{}
|
|
}
|
|
raw, err := json.Marshal(sections)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE users SET sidebar_collapsed = ?, updated_at = ? WHERE id = ?`,
|
|
string(raw), time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Person is the public half of a user: what somebody else is allowed to see when
|
|
// their name has to appear next to a task. No address, no hash, no timezone.
|
|
type Person struct {
|
|
ID string
|
|
Name string
|
|
AvatarColor string
|
|
}
|
|
|
|
// PeopleByIDs looks up several users at once.
|
|
//
|
|
// One query rather than a loop of UserByID, because the caller is resolving the
|
|
// assignees of a list of tasks and a per-row lookup is a query per row. Unknown
|
|
// ids are simply absent from the result; a user who has been deleted is not an
|
|
// error for a view that is only trying to put a name on a row.
|
|
func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person, error) {
|
|
people := map[string]Person{}
|
|
if len(ids) == 0 {
|
|
return people, nil
|
|
}
|
|
|
|
args := make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT id, name, avatar_color FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var p Person
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
|
return nil, err
|
|
}
|
|
people[p.ID] = p
|
|
}
|
|
return people, rows.Err()
|
|
}
|
|
|
|
// UserSummary is a user as the administrator's list shows them: the account, plus
|
|
// the two numbers that say what deleting it would take with it and the one that
|
|
// says whether it is in use at all.
|
|
type UserSummary struct {
|
|
User
|
|
// LastSeenAt is the most recent request from any of their sessions, zero if
|
|
// they have never signed in — which is what an invite that was accepted and
|
|
// then forgotten looks like.
|
|
LastSeenAt time.Time
|
|
// ProjectCount excludes the Inbox: every account has one, so counting it would
|
|
// make "1 project" mean "nothing".
|
|
ProjectCount int
|
|
// TaskCount is everything a delete would destroy: the tasks in the projects
|
|
// they own, which go with those projects. Tasks they merely *wrote* in
|
|
// somebody else's project are not counted, because they no longer go —
|
|
// `created_by` is ON DELETE SET NULL, so that work stays and loses its author.
|
|
TaskCount int
|
|
// AuthoredElsewhere is what a delete leaves behind unattributed: tasks they
|
|
// wrote in projects other people own. Separate from TaskCount because it is a
|
|
// different sentence — "this much disappears" and "this much stays without a
|
|
// name on it" — and running them together was what made the old number sound
|
|
// like a threat it no longer is.
|
|
AuthoredElsewhere int
|
|
}
|
|
|
|
// ListUsers returns every account on the instance. Administrators only — this is
|
|
// the whole membership of the server, which is not a list anybody else is owed.
|
|
func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT u.id, u.email, u.name, u.avatar_color, u.is_admin, u.created_at,
|
|
COALESCE((SELECT max(s.last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0),
|
|
(SELECT count(*) FROM projects p
|
|
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
|
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
WHERE t.deleted_at IS NULL AND p.owner_id = u.id),
|
|
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
|
WHERE t.deleted_at IS NULL AND t.created_by = u.id AND p.owner_id <> u.id)
|
|
FROM users u
|
|
ORDER BY u.is_admin DESC, u.name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []UserSummary{}
|
|
for rows.Next() {
|
|
var s UserSummary
|
|
var isAdmin int
|
|
var created, lastSeen int64
|
|
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
|
|
&lastSeen, &s.ProjectCount, &s.TaskCount, &s.AuthoredElsewhere); err != nil {
|
|
return nil, err
|
|
}
|
|
s.IsAdmin = isAdmin == 1
|
|
s.CreatedAt = time.Unix(created, 0).UTC()
|
|
if lastSeen > 0 {
|
|
s.LastSeenAt = time.Unix(lastSeen, 0).UTC()
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CountAdmins is what stops the last administrator from being deleted or demoted.
|
|
// An instance with no administrator has no way back: there is no console, and the
|
|
// setup route refuses to run once an account exists.
|
|
func (db *DB) CountAdmins(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users WHERE is_admin = 1`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`,
|
|
boolToInt(admin), time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteUser removes an account and everything that hangs off it.
|
|
//
|
|
// A hard delete, and the only one in this database. Every other delete here is a
|
|
// `deleted_at` with a trash behind it; this one relies on the foreign keys and
|
|
// there is no way back — which is why the handler sends the counts to the
|
|
// interface first.
|
|
//
|
|
// What goes: `projects.owner_id` cascades, so their projects go and every task in
|
|
// them goes too. That is the whole of it, and the handler sends the count to the
|
|
// interface first.
|
|
//
|
|
// What stays: everything they wrote in somebody else's project. `tasks.created_by`
|
|
// used to cascade as well, which meant retiring a colleague's account removed
|
|
// their contributions from shared work — see migration 0008, which rebuilt the
|
|
// table to make it ON DELETE SET NULL. The task survives and loses its author,
|
|
// which is the honest record of what happened.
|
|
//
|
|
// Tasks merely *assigned* to them are unassigned by `assignee_id ON DELETE SET
|
|
// NULL`, and completions by the same rule on `completed_by`.
|
|
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
|
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPeople returns everybody the caller shares at least one project with, plus
|
|
// the caller.
|
|
//
|
|
// The set the interface needs to put a name on an `assignee_id` — in a task row,
|
|
// in a filter, anywhere a person appears next to work. Fetched once rather than
|
|
// looked up per row, and deliberately *not* the instance's user list: that is the
|
|
// administrator's page, and a task list has no business enumerating everybody with
|
|
// an account here.
|
|
//
|
|
// Themselves included, because a client comparing "is this me" against the same
|
|
// list it draws from has one source rather than two.
|
|
func (db *DB) ListPeople(ctx context.Context, userID string) ([]Person, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT DISTINCT u.id, u.name, u.avatar_color
|
|
FROM users u
|
|
WHERE u.id = ?
|
|
-- the owners of projects the caller is a member of
|
|
OR u.id IN (SELECT p.owner_id FROM projects p
|
|
JOIN project_members m ON m.project_id = p.id
|
|
WHERE m.user_id = ? AND p.deleted_at IS NULL)
|
|
-- and the members of every project the caller can see
|
|
OR u.id IN (SELECT m2.user_id FROM project_members m2
|
|
JOIN projects p2 ON p2.id = m2.project_id
|
|
WHERE p2.deleted_at IS NULL
|
|
AND (p2.owner_id = ?
|
|
OR EXISTS (SELECT 1 FROM project_members m3
|
|
WHERE m3.project_id = p2.id AND m3.user_id = ?)))
|
|
ORDER BY u.name`, userID, userID, userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
people := []Person{}
|
|
for rows.Next() {
|
|
var p Person
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
|
|
return nil, err
|
|
}
|
|
people = append(people, p)
|
|
}
|
|
return people, rows.Err()
|
|
}
|
|
|
|
// UserCount is what decides whether the instance still needs its first admin.
|
|
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// UpdatePasswordHash also clears every session but the one asking, so changing a
|
|
// password actually ends the sessions of whoever else was using the account. That
|
|
// is most of the reason people change one.
|
|
func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionID string) error {
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx,
|
|
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`,
|
|
hash, time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(ctx,
|
|
`DELETE FROM sessions WHERE user_id = ? AND id <> ?`, userID, keepSessionID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// UpdateProfile changes the things a person may change about themselves.
|
|
//
|
|
// The email address is not among them: it identifies the account, it is what
|
|
// invites were sent to, and changing it is a re-verification flow rather than a
|
|
// field. Neither is is_admin — an account cannot promote itself.
|
|
func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error {
|
|
res, err := db.ExecContext(ctx,
|
|
`UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`,
|
|
name, timezone, locale, time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
|
var value any
|
|
if secret != "" {
|
|
value = secret
|
|
}
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
|
value, boolToInt(enabled), time.Now().Unix(), userID)
|
|
return err
|
|
}
|
|
|
|
// InboxID returns the user's Inbox project.
|
|
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
|
var id string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id FROM projects WHERE owner_id = ? AND is_inbox = 1 AND deleted_at IS NULL`,
|
|
userID).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// avatarColorFor picks a stable colour from the address, so a person looks the same
|
|
// to everyone who shares a project with them without anyone having to choose one.
|
|
func avatarColorFor(seed string) string {
|
|
// Muted tones only: an avatar is a small mark beside text, not a highlight.
|
|
palette := []string{
|
|
"#8a8f98", "#7d8c7a", "#9a8577", "#7a8a9a", "#8f7f92",
|
|
"#a08a6b", "#6f8a86", "#947d7d",
|
|
}
|
|
var sum uint32
|
|
for i := 0; i < len(seed); i++ {
|
|
sum = sum*31 + uint32(seed[i])
|
|
}
|
|
return palette[sum%uint32(len(palette))]
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if strings.TrimSpace(v) == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// isUniqueViolation recognises a broken UNIQUE constraint without depending on the
|
|
// driver's error type, which modernc's SQLite does not export in a usable form.
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
|
}
|
|
|
|
// --- calendar feed tokens -------------------------------------------------------
|
|
|
|
// EnsureICSToken returns the user's feed token, minting one the first time it is
|
|
// asked for. Created lazily rather than at signup so that somebody who never
|
|
// subscribes to a calendar never has a standing credential they did not ask for.
|
|
func (db *DB) EnsureICSToken(ctx context.Context, userID string) (string, error) {
|
|
var token sql.NullString
|
|
err := db.QueryRowContext(ctx, `SELECT ics_token FROM users WHERE id = ?`, userID).Scan(&token)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if token.Valid && token.String != "" {
|
|
return token.String, nil
|
|
}
|
|
|
|
fresh, err := auth.NewToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := db.SetICSToken(ctx, userID, fresh); err != nil {
|
|
return "", err
|
|
}
|
|
return fresh, nil
|
|
}
|
|
|
|
func (db *DB) SetICSToken(ctx context.Context, userID, token string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE users SET ics_token = ?, updated_at = ? WHERE id = ?`,
|
|
token, time.Now().Unix(), userID)
|
|
return err
|
|
}
|
|
|
|
// UserByICSToken resolves a feed URL to its owner. The token is the whole
|
|
// credential, so it is compared as an exact match on an indexed unique column and
|
|
// nothing else about the request is trusted.
|
|
func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
|
|
if token == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
|
|
}
|