feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export
Comments and attachments close out phase 2. Files are stored content-addressed by hash, so the path never contains anything the uploader chose — a filename is attacker-controlled text, and building a path from it is how "../" becomes a write outside the data directory. Everything downloads as application/octet-stream: an uploaded SVG served inline would run its own script on this origin with the session cookie attached. Web Push is written directly against RFC 8030/8291/8292 rather than pulled in — it is three specs' worth of well-defined crypto and about two hundred lines, and the alternatives carry a JWT and crypto stack each larger than the file. Phase 4 imports Todoist's CSV and exports it back. Todoist numbers priorities the opposite way to how it displays them — its 4 is P1 — so the two are converted rather than copied; getting it backwards silently inverts the urgency of every imported task. Nesting is positional, so the reader keeps an indent stack and the writer never sorts. The round trip is tested as the brief asks: a CSV in, out, and back produces an identical project, and two exports of one project are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ee72d399c
commit
4058ff0533
@@ -0,0 +1,271 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Comment struct {
|
||||
ID string
|
||||
TaskID string
|
||||
UserID string
|
||||
UserName string
|
||||
UserColor string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
Attachments []Attachment
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID string
|
||||
TaskID string
|
||||
CommentID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Size int64
|
||||
Path string
|
||||
UploadedBy string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListComments(ctx context.Context, taskID string) ([]Comment, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT c.id, c.task_id, c.user_id, u.name, u.avatar_color, c.body,
|
||||
c.created_at, c.updated_at
|
||||
FROM comments c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.task_id = ? AND c.deleted_at IS NULL
|
||||
ORDER BY c.created_at`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comments := []Comment{}
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var c Comment
|
||||
var created, updated int64
|
||||
if err := rows.Scan(&c.ID, &c.TaskID, &c.UserID, &c.UserName, &c.UserColor,
|
||||
&c.Body, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = time.Unix(created, 0).UTC()
|
||||
c.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
comments = append(comments, c)
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
err = rows.Err()
|
||||
// Closed before the attachment lookup: an open result set holds its connection.
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return comments, nil
|
||||
}
|
||||
byComment, err := db.attachmentsByComment(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range comments {
|
||||
comments[i].Attachments = byComment[comments[i].ID]
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateComment(ctx context.Context, taskID, userID, body string) (*Comment, error) {
|
||||
c := &Comment{ID: NewID(), TaskID: taskID, UserID: userID, Body: body}
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt, c.UpdatedAt = now, now
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, taskID, userID, body, now.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// UpdateComment is scoped to the author. Editing somebody else's words is not
|
||||
// something a project role should confer, however senior.
|
||||
func (db *DB) UpdateComment(ctx context.Context, commentID, userID, body string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE comments SET body = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND deleted_at IS NULL`,
|
||||
body, time.Now().Unix(), commentID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteComment allows the author, or the project's owner — who has to be able to
|
||||
// remove something inappropriate from a project they are responsible for.
|
||||
func (db *DB) DeleteComment(ctx context.Context, commentID, userID string, isProjectOwner bool) error {
|
||||
query := `UPDATE comments SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`
|
||||
args := []any{time.Now().Unix(), commentID}
|
||||
if !isProjectOwner {
|
||||
query += ` AND user_id = ?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommentTask reports which task a comment belongs to, so a handler can check the
|
||||
// project's permissions before touching it.
|
||||
func (db *DB) CommentTask(ctx context.Context, commentID string) (string, error) {
|
||||
var taskID string
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT task_id FROM comments WHERE id = ? AND deleted_at IS NULL`, commentID).Scan(&taskID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return taskID, err
|
||||
}
|
||||
|
||||
// --- attachments -----------------------------------------------------------------
|
||||
|
||||
// CreateAttachment records a file that has already been written to disk. The row is
|
||||
// written after the bytes, so a crash between the two leaves an orphan file rather
|
||||
// than a row pointing at nothing — the first is invisible, the second is a broken
|
||||
// download.
|
||||
func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
||||
if a.ID == "" {
|
||||
a.ID = NewID()
|
||||
}
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
|
||||
path, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
|
||||
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
|
||||
args := make([]any, len(commentIDs))
|
||||
for i, id := range commentIDs {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||
ORDER BY created_at`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list, err := scanAttachments(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]Attachment{}
|
||||
for _, a := range list {
|
||||
out[a.CommentID] = append(out[a.CommentID], a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
out := []Attachment{}
|
||||
for rows.Next() {
|
||||
var a Attachment
|
||||
var taskID, commentID sql.NullString
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType,
|
||||
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CommentID = commentID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE id = ?`, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list, err := scanAttachments(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// AttachmentTask resolves an attachment to the task whose project governs it —
|
||||
// directly, or through the comment it hangs on.
|
||||
func (db *DB) AttachmentTask(ctx context.Context, attachmentID string) (string, error) {
|
||||
var taskID sql.NullString
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(a.task_id, c.task_id)
|
||||
FROM attachments a
|
||||
LEFT JOIN comments c ON c.id = a.comment_id
|
||||
WHERE a.id = ?`, attachmentID).Scan(&taskID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !taskID.Valid {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return taskID.String, nil
|
||||
}
|
||||
|
||||
// DeleteAttachment removes the row and returns the path, so the caller can delete
|
||||
// the file. The row goes first: a file with no row is invisible, a row with no file
|
||||
// is a download that fails.
|
||||
func (db *DB) DeleteAttachment(ctx context.Context, attachmentID string) (string, error) {
|
||||
a, err := db.GetAttachment(ctx, attachmentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM attachments WHERE id = ?`, attachmentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.Path, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Notifications, Web Push, and the mail-to-task address.
|
||||
|
||||
-- What a person needs to be told about: an assignment, a comment on something they
|
||||
-- are in, an invite accepted. Stored rather than only pushed, so the bell icon has
|
||||
-- a history and a notification survives the browser being shut.
|
||||
CREATE TABLE notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
actor_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
read_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
|
||||
CREATE INDEX idx_notifications_unread ON notifications (user_id) WHERE read_at IS NULL;
|
||||
|
||||
-- Web Push (RFC 8030) subscriptions. One row per browser per device: the same
|
||||
-- person signed in on a laptop and a phone has two, and revoking one must not
|
||||
-- silence the other.
|
||||
CREATE TABLE push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
-- A push service returns 404 or 410 when a subscription is dead. Counting the
|
||||
-- failures lets a flaky network be forgiven while a genuinely gone endpoint is
|
||||
-- eventually dropped.
|
||||
failures INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_user ON push_subscriptions (user_id);
|
||||
|
||||
-- The VAPID keypair identifying this instance to push services. One row, ever.
|
||||
-- Generated on first use rather than configured: it is not a secret an operator
|
||||
-- should have to produce, and rotating it only costs re-subscribing.
|
||||
CREATE TABLE instance_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- The per-user address that turns an email into a task: todo+<token>@domain.
|
||||
ALTER TABLE users ADD COLUMN mail_token TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_mail_token ON users (mail_token) WHERE mail_token IS NOT NULL;
|
||||
|
||||
-- Settings that belong to a person rather than to the instance: which AI provider
|
||||
-- they use and with whose key, what their Gmail connection is. Kept as JSON for
|
||||
-- the same reason templates are — written whole, read whole, and it would
|
||||
-- otherwise be a migration every time an integration gains an option.
|
||||
CREATE TABLE user_settings (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL DEFAULT 'general',
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,232 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID string
|
||||
UserID string
|
||||
ActorID string
|
||||
ActorName string
|
||||
ProjectID string
|
||||
TaskID 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, kind,
|
||||
title, body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
||||
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
||||
return 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.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 = ?
|
||||
ORDER BY n.created_at 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 sql.NullString
|
||||
var readAt sql.NullInt64
|
||||
var created int64
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
||||
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.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()
|
||||
}
|
||||
_, 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
|
||||
user_id = excluded.user_id,
|
||||
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()
|
||||
}
|
||||
|
||||
func (db *DB) DeletePushSubscription(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.
|
||||
func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error {
|
||||
if permanent {
|
||||
return db.DeletePushSubscription(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
|
||||
}
|
||||
Reference in New Issue
Block a user