Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der manglede, var en URL, man kan smide en linje tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej, der bare sender det, man har markeret. Første linje bliver til opgaven, resten til noten under den. Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i feltet øverst i fladen — dét er hele pointen med at have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder. Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i andres adressebøger — skifter med. Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på en anden maskine, og en, man ikke kan. Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet: forudindlæsning i en browser, en linkkontrol i en chat og en historik, der bliver besøgt igen, ville alle lave opgaver, ingen har bedt om. Sin egen kvote frem for postens, så en travl integration ikke kan lukke for den indgående post. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
540 lines
18 KiB
Go
540 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
)
|
|
|
|
type Notification struct {
|
|
ID string
|
|
UserID string
|
|
ActorID string
|
|
ActorName string
|
|
ProjectID string
|
|
TaskID string
|
|
NoteID string
|
|
Kind string
|
|
Title string
|
|
Body string
|
|
ReadAt *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
|
if n.ID == "" {
|
|
n.ID = NewID()
|
|
}
|
|
n.CreatedAt = time.Now().UTC()
|
|
|
|
_, err := db.ExecContext(ctx,
|
|
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
|
|
title, body, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
|
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
|
return err
|
|
}
|
|
|
|
// BumpUnreadNoteNotification folds a second change to the same note into the first,
|
|
// and says whether it found one to fold into.
|
|
//
|
|
// A note is saved on a typing pause, so writing a row per save would turn one
|
|
// person editing one note for five minutes into a column of twenty identical
|
|
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
|
|
// der rørte den sidst.
|
|
//
|
|
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
|
|
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
|
|
// gentagelse af noget, man allerede har set.
|
|
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
|
|
res, err := db.ExecContext(ctx, `
|
|
UPDATE notifications
|
|
SET created_at = ?, actor_id = ?, title = ?, body = ?
|
|
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
|
|
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, err := res.RowsAffected()
|
|
return n > 0, err
|
|
}
|
|
|
|
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 30
|
|
}
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
|
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
|
|
FROM notifications n
|
|
LEFT JOIN users u ON u.id = n.actor_id
|
|
WHERE n.user_id = ?
|
|
-- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver
|
|
-- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på
|
|
-- afgør basen selv, hvilken der står øverst, og klokken viser dem i den
|
|
-- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er
|
|
-- den rigtige afgørelse at give den — og den er den samme hver gang.
|
|
ORDER BY n.created_at DESC, n.id DESC
|
|
LIMIT ?`, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Notification{}
|
|
for rows.Next() {
|
|
var n Notification
|
|
var actorID, projectID, taskID, noteID sql.NullString
|
|
var readAt sql.NullInt64
|
|
var created int64
|
|
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
|
&taskID, ¬eID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
|
return nil, err
|
|
}
|
|
n.ActorID, n.ProjectID = actorID.String, projectID.String
|
|
n.TaskID, n.NoteID = taskID.String, noteID.String
|
|
if readAt.Valid {
|
|
v := time.Unix(readAt.Int64, 0).UTC()
|
|
n.ReadAt = &v
|
|
}
|
|
n.CreatedAt = time.Unix(created, 0).UTC()
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (db *DB) UnreadNotificationCount(ctx context.Context, userID string) (int, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT count(*) FROM notifications WHERE user_id = ? AND read_at IS NULL`,
|
|
userID).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// MarkNotificationsRead marks one, or all of them when id is empty.
|
|
func (db *DB) MarkNotificationsRead(ctx context.Context, userID, id string) error {
|
|
query := `UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL`
|
|
args := []any{time.Now().Unix(), userID}
|
|
if id != "" {
|
|
query += ` AND id = ?`
|
|
args = append(args, id)
|
|
}
|
|
_, err := db.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// ProjectMemberIDs is everyone who can see a project, owner included — the audience
|
|
// for a notification about something that happened inside it.
|
|
func (db *DB) ProjectMemberIDs(ctx context.Context, projectID string) ([]string, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT owner_id FROM projects WHERE id = ?
|
|
UNION
|
|
SELECT user_id FROM project_members WHERE project_id = ?`, projectID, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// --- Web Push subscriptions --------------------------------------------------------
|
|
|
|
type PushSubscription struct {
|
|
ID string
|
|
UserID string
|
|
Endpoint string
|
|
P256dh string
|
|
Auth string
|
|
UserAgent string
|
|
Failures int
|
|
}
|
|
|
|
// SavePushSubscription is an upsert on the endpoint: a browser that re-subscribes
|
|
// produces the same endpoint, and a second row would mean two identical
|
|
// notifications on one device.
|
|
func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) error {
|
|
if sub.ID == "" {
|
|
sub.ID = NewID()
|
|
}
|
|
// The upsert no longer moves the row between accounts.
|
|
//
|
|
// `user_id = excluded.user_id` was in the DO UPDATE, so anybody who learned
|
|
// another person's endpoint could claim it by subscribing with it: the
|
|
// victim's device would then receive the claimant's notifications and none of
|
|
// its own. A re-subscribe from the same person still refreshes the keys, which
|
|
// is what the upsert is actually for.
|
|
_, err := db.ExecContext(ctx,
|
|
`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (endpoint) DO UPDATE SET
|
|
p256dh = excluded.p256dh,
|
|
auth = excluded.auth,
|
|
user_agent = excluded.user_agent,
|
|
failures = 0`,
|
|
sub.ID, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth, sub.UserAgent,
|
|
time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushSubscription, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT id, user_id, endpoint, p256dh, auth, user_agent, failures
|
|
FROM push_subscriptions WHERE user_id = ?`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []PushSubscription{}
|
|
for rows.Next() {
|
|
var s PushSubscription
|
|
if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth,
|
|
&s.UserAgent, &s.Failures); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DeletePushSubscription removes one of this person's devices.
|
|
//
|
|
// Scoped by user as well as by endpoint. It was endpoint alone, which meant
|
|
// anybody who learned somebody else's endpoint could silently unsubscribe their
|
|
// phone — and the endpoint is the only thing about a subscription that ever
|
|
// leaves the browser.
|
|
func (db *DB) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, endpoint, userID)
|
|
return err
|
|
}
|
|
|
|
// dropPushEndpoint removes a subscription the push service has declared dead.
|
|
// Unexported so the only *request* path to a delete stays the scoped one.
|
|
func (db *DB) dropPushEndpoint(ctx context.Context, endpoint string) error {
|
|
_, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
|
return err
|
|
}
|
|
|
|
// RecordPushFailure counts a delivery failure and drops the subscription once it
|
|
// has failed enough times. A push service says 404 or 410 when an endpoint is gone,
|
|
// but it also has bad days — forgiving a few failures keeps a flaky network from
|
|
// silently unsubscribing somebody's phone.
|
|
// MarkPushDelivered records that a push went through: the failure count goes back
|
|
// to zero and last_used_at moves forward. Without it a subscription that failed a
|
|
// few times and then recovered would keep those failures on its record and be one
|
|
// flaky night away from the ten that delete it — even though it is working.
|
|
func (db *DB) MarkPushDelivered(ctx context.Context, endpoint string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE push_subscriptions SET failures = 0, last_used_at = ? WHERE endpoint = ?`,
|
|
time.Now().Unix(), endpoint)
|
|
return err
|
|
}
|
|
|
|
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
|
if permanent {
|
|
// Not scoped by user, and that is right here: this is the push service
|
|
// saying the endpoint is gone, not a person asking for something. There is
|
|
// no caller to check against — the caller is the delivery job.
|
|
return db.dropPushEndpoint(ctx, endpoint)
|
|
}
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
`DELETE FROM push_subscriptions WHERE endpoint = ? AND failures >= 10`, endpoint)
|
|
return err
|
|
}
|
|
|
|
// --- VAPID keypair ------------------------------------------------------------------
|
|
|
|
// InstanceKeys returns the VAPID keypair, generating it on first use.
|
|
//
|
|
// Generated rather than configured: it identifies this instance to push services
|
|
// and is not something an operator should have to produce. Rotating it only costs
|
|
// every browser re-subscribing.
|
|
func (db *DB) InstanceKeys(ctx context.Context, generate func() (public, private string, err error)) (string, string, error) {
|
|
var public, private string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT public_key, private_key FROM instance_keys LIMIT 1`).Scan(&public, &private)
|
|
if err == nil {
|
|
return public, private, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return "", "", err
|
|
}
|
|
|
|
public, private, err = generate()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
_, err = db.ExecContext(ctx,
|
|
`INSERT INTO instance_keys (id, public_key, private_key, created_at) VALUES (?, ?, ?, ?)`,
|
|
NewID(), public, private, time.Now().Unix())
|
|
if err != nil {
|
|
// Another request generated one first; take theirs so the two agree.
|
|
var existingPub, existingPriv string
|
|
if qErr := db.QueryRowContext(ctx,
|
|
`SELECT public_key, private_key FROM instance_keys LIMIT 1`).
|
|
Scan(&existingPub, &existingPriv); qErr == nil {
|
|
return existingPub, existingPriv, nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
return public, private, nil
|
|
}
|
|
|
|
// --- mail-to-task tokens -------------------------------------------------------------
|
|
|
|
func (db *DB) EnsureMailToken(ctx context.Context, userID string) (string, error) {
|
|
var token sql.NullString
|
|
err := db.QueryRowContext(ctx, `SELECT mail_token FROM users WHERE id = ?`, userID).Scan(&token)
|
|
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.SetMailToken(ctx, userID, fresh); err != nil {
|
|
return "", err
|
|
}
|
|
return fresh, nil
|
|
}
|
|
|
|
func (db *DB) SetMailToken(ctx context.Context, userID, token string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE users SET mail_token = ?, updated_at = ? WHERE id = ?`,
|
|
token, time.Now().Unix(), userID)
|
|
return err
|
|
}
|
|
|
|
// --- krogen ind i indbakken ------------------------------------------------------
|
|
|
|
// EnsureHookToken returns the token in the person's webhook URL, minting one the
|
|
// first time it is asked for.
|
|
//
|
|
// Sit eget token frem for mailens. De to er den samme slags hemmelighed, men de
|
|
// slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der
|
|
// blev væk, skal kunne skiftes uden at mailadressen — som står i andres
|
|
// adressebøger — skifter med.
|
|
func (db *DB) EnsureHookToken(ctx context.Context, userID string) (string, error) {
|
|
var token sql.NullString
|
|
err := db.QueryRowContext(ctx, `SELECT hook_token FROM users WHERE id = ?`, userID).Scan(&token)
|
|
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.SetHookToken(ctx, userID, fresh); err != nil {
|
|
return "", err
|
|
}
|
|
return fresh, nil
|
|
}
|
|
|
|
func (db *DB) SetHookToken(ctx context.Context, userID, token string) error {
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE users SET hook_token = ?, updated_at = ? WHERE id = ?`,
|
|
token, time.Now().Unix(), userID)
|
|
return err
|
|
}
|
|
|
|
// UserByHookToken is who a webhook call is on behalf of. Tokenet er hele
|
|
// legitimationen, så et tomt et er et nej med det samme frem for et opslag, der
|
|
// kunne ramme en række, hvor søjlen aldrig er sat.
|
|
func (db *DB) UserByHookToken(ctx context.Context, token string) (*User, error) {
|
|
if token == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE hook_token = ?`, token)
|
|
}
|
|
|
|
func (db *DB) UserByMailToken(ctx context.Context, token string) (*User, error) {
|
|
if token == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE mail_token = ?`, token)
|
|
}
|
|
|
|
// --- per-user settings ------------------------------------------------------------------
|
|
|
|
// UserSettings reads one scope of a person's integration settings. A missing row is
|
|
// an empty map, not an error: nobody has settings until they set some.
|
|
// --- sealed values ----------------------------------------------------------------
|
|
|
|
// secretFields are the keys whose values never leave this process in the clear.
|
|
//
|
|
// A named set rather than "seal everything": the rest of a settings blob is what
|
|
// somebody reads when a mailbox misbehaves — which folder, which label, which
|
|
// address — and turning all of it into ciphertext would buy nothing and cost the
|
|
// ability to look.
|
|
var secretFields = map[string]bool{
|
|
"refresh_token": true,
|
|
"access_token": true,
|
|
"password": true,
|
|
"client_secret": true,
|
|
// The AI provider's key. Missed when this list was written, and it is exactly
|
|
// the same kind of thing as the rest: a live credential, in a settings blob,
|
|
// in a database anybody with a backup file can read. Existing rows convert
|
|
// themselves the next time the settings are saved.
|
|
"api_key": true,
|
|
}
|
|
|
|
// seal returns a copy of values with the secret fields sealed. Without a key it
|
|
// returns them untouched, which is what the tests that never set one expect.
|
|
func (db *DB) seal(values map[string]any) (map[string]any, error) {
|
|
if db.box == nil {
|
|
return values, nil
|
|
}
|
|
out := make(map[string]any, len(values))
|
|
for k, v := range values {
|
|
s, ok := v.(string)
|
|
if !secretFields[k] || !ok {
|
|
out[k] = v
|
|
continue
|
|
}
|
|
sealed, err := db.box.Seal(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("seal %s: %w", k, err)
|
|
}
|
|
out[k] = sealed
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// unseal is seal's other direction. Values written before there was a key come
|
|
// back as themselves; the next write puts them away properly, so the column
|
|
// converts itself as it is used rather than in one migration that has to be right
|
|
// about every row at once.
|
|
func (db *DB) unseal(values map[string]any) (map[string]any, error) {
|
|
if db.box == nil {
|
|
return values, nil
|
|
}
|
|
for k, v := range values {
|
|
s, ok := v.(string)
|
|
if !secretFields[k] || !ok {
|
|
continue
|
|
}
|
|
plain, err := db.box.Unseal(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", k, err)
|
|
}
|
|
values[k] = plain
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
|
|
var raw string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT values_json FROM user_settings WHERE user_id = ? AND scope = ?`,
|
|
userID, scope).Scan(&raw)
|
|
if err == sql.ErrNoRows {
|
|
return map[string]any{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
values := map[string]any{}
|
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
return db.unseal(values)
|
|
}
|
|
|
|
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
|
|
values, err := db.seal(values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// On (user_id, scope), which is the key since 0025. It used to be on `user_id`
|
|
// alone and set `scope = excluded.scope` — so writing one scope did not add a
|
|
// row beside the others, it took theirs and renamed it. The `ai`, `gmail` and
|
|
// `calendar` settings each deleted the other two, and the write reported
|
|
// success every time.
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (user_id, scope) DO UPDATE SET
|
|
values_json = excluded.values_json,
|
|
updated_at = excluded.updated_at`,
|
|
userID, scope, string(raw), time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
// --- instance-wide settings -------------------------------------------------------------
|
|
|
|
// InstanceSettings reads one scope of the instance's own settings. A missing row
|
|
// is an empty map, not an error.
|
|
//
|
|
// Separate from UserSettings because some configuration is the server's rather
|
|
// than a person's — the Gmail OAuth client is one registration that everybody
|
|
// connecting a mailbox goes through, not a preference each of them holds.
|
|
func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]any, error) {
|
|
var raw string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT values_json FROM instance_settings WHERE scope = ?`, scope).Scan(&raw)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return map[string]any{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := map[string]any{}
|
|
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
return db.unseal(values)
|
|
}
|
|
|
|
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
|
|
values, err := db.seal(values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO instance_settings (scope, values_json, updated_at)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT (scope) DO UPDATE SET
|
|
values_json = excluded.values_json,
|
|
updated_at = excluded.updated_at`,
|
|
scope, string(raw), time.Now().Unix())
|
|
return err
|
|
}
|