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,414 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
type commentJSON struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
UserID string `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
UserColor string `json:"user_color"`
|
||||
Body string `json:"body"`
|
||||
Attachments []attachmentJSON `json:"attachments"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type attachmentJSON struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func toAttachmentJSON(a store.Attachment) attachmentJSON {
|
||||
return attachmentJSON{
|
||||
ID: a.ID, Filename: a.Filename, MimeType: a.MimeType, Size: a.Size,
|
||||
URL: "/api/v1/attachments/" + a.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func toCommentJSON(c store.Comment) commentJSON {
|
||||
j := commentJSON{
|
||||
ID: c.ID, TaskID: c.TaskID, UserID: c.UserID, UserName: c.UserName,
|
||||
UserColor: c.UserColor, Body: c.Body,
|
||||
Attachments: make([]attachmentJSON, 0, len(c.Attachments)),
|
||||
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
for _, a := range c.Attachments {
|
||||
j.Attachments = append(j.Attachments, toAttachmentJSON(a))
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list comments", err)
|
||||
return
|
||||
}
|
||||
out := make([]commentJSON, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
out = append(out, toCommentJSON(c))
|
||||
}
|
||||
|
||||
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list attachments", err)
|
||||
return
|
||||
}
|
||||
direct := make([]attachmentJSON, 0, len(files))
|
||||
for _, a := range files {
|
||||
direct = append(direct, toAttachmentJSON(a))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"comments": out, "attachments": direct})
|
||||
}
|
||||
|
||||
type commentRequest struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req commentRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if body == "" {
|
||||
writeFieldErrors(w, map[string]string{"body": "required"})
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(body) > 10000 {
|
||||
writeFieldErrors(w, map[string]string{"body": "must be 10000 characters or fewer"})
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||
if err != nil {
|
||||
s.internal(w, "create comment", err)
|
||||
return
|
||||
}
|
||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err == nil {
|
||||
s.activity(r, task.ProjectID, taskID, "comment.created", map[string]any{"body": body})
|
||||
s.publish(task.ProjectID, "comment.created", toCommentJSON(*comment))
|
||||
// Everybody else who can see the project hears about it; the author does
|
||||
// not need telling what they just wrote.
|
||||
s.notifyProject(r, task.ProjectID, taskID, user.ID, "comment",
|
||||
user.Name+" kommenterede", task.Content)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toCommentJSON(*comment))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
var req commentRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
body := strings.TrimSpace(req.Body)
|
||||
if body == "" {
|
||||
writeFieldErrors(w, map[string]string{"body": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||
if err != nil {
|
||||
s.storeError(w, "update comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentID := chi.URLParam(r, "commentID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "comment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
||||
s.storeError(w, "delete comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- attachments -------------------------------------------------------------------
|
||||
|
||||
// maxUploadBytes caps a single file at 25 MiB.
|
||||
//
|
||||
// This is a to-do app on somebody's homelab, not a file host: the attachments that
|
||||
// matter are a photo of a receipt or a PDF of a contract. A cap keeps one paste of
|
||||
// a video from filling the volume the database lives on.
|
||||
const maxUploadBytes = 25 << 20
|
||||
|
||||
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := chi.URLParam(r, "taskID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
TaskID: taskID,
|
||||
CommentID: r.FormValue("comment_id"),
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
// An attachment hangs on a task or on a comment, never both — the schema says
|
||||
// so, and the form may have supplied a comment id.
|
||||
if a.CommentID != "" {
|
||||
a.TaskID = ""
|
||||
}
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, "record attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
if task, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
|
||||
s.activity(r, task.ProjectID, taskID, "attachment.added",
|
||||
map[string]any{"filename": a.Filename})
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// storeUpload writes the bytes under a content-addressed path.
|
||||
//
|
||||
// Naming by hash means the same file attached to ten tasks is stored once, and —
|
||||
// more importantly — the path never contains anything the uploader chose. A
|
||||
// filename is attacker-controlled text; using it to build a path is how "../"
|
||||
// becomes a write outside the data directory.
|
||||
func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
|
||||
// Created here rather than assumed: startup makes the directory, but a volume
|
||||
// remounted underneath a running container would otherwise turn every upload
|
||||
// into a 500 with nothing in the log explaining why.
|
||||
if err := os.MkdirAll(s.cfg.FilesDir(), 0o750); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.cfg.FilesDir(), "upload-*")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath) // no-op once it has been renamed away
|
||||
|
||||
hash := sha256.New()
|
||||
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return "", 0, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(hash.Sum(nil))
|
||||
// Two levels of fan-out: tens of thousands of files in one directory is slow
|
||||
// to list and slow to open on most filesystems.
|
||||
rel := filepath.Join(sum[:2], sum[2:4], sum)
|
||||
full := filepath.Join(s.cfg.FilesDir(), rel)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
// Already stored by an earlier upload of the identical file.
|
||||
return rel, size, nil
|
||||
}
|
||||
if err := os.Rename(tmpPath, full); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return rel, size, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
}
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The stored path came from a hash this server computed, but it is still
|
||||
// checked against the files directory before being opened — a path traversal
|
||||
// that got into the database must not become one that reads /etc/passwd.
|
||||
full := filepath.Join(s.cfg.FilesDir(), filepath.Clean("/"+a.Path))
|
||||
if !strings.HasPrefix(full, s.cfg.FilesDir()+string(os.PathSeparator)) {
|
||||
s.log.Error("attachment path escapes the files directory", "id", a.ID, "path", a.Path)
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(full)
|
||||
if err != nil {
|
||||
s.log.Error("open attachment", "err", err, "id", a.ID)
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Everything is served as a download rather than rendered. An uploaded SVG or
|
||||
// HTML file displayed inline would run its own script on this origin, with the
|
||||
// session cookie attached — attachments are the one place a user supplies
|
||||
// content that another user opens.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
|
||||
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
|
||||
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The file is left on disk. It is content-addressed, so another attachment may
|
||||
// point at the same bytes; removing it here would break that one. A sweeper
|
||||
// for genuinely unreferenced files is a separate, safer job.
|
||||
s.log.Debug("attachment row removed; file retained", "path", path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// safeFilename keeps the name for display only — never for a path — but still
|
||||
// strips the parts that would confuse a browser saving it.
|
||||
func safeFilename(name string) string {
|
||||
name = filepath.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 || r == '/' || r == '"' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "fil"
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 200 {
|
||||
runes := []rune(name)
|
||||
name = string(runes[:200])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// detectMime records what was declared, for display. It is never used to decide how
|
||||
// to serve the file — see the download handler.
|
||||
func detectMime(filename, declared string) string {
|
||||
if declared != "" && declared != "application/octet-stream" {
|
||||
return declared
|
||||
}
|
||||
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
|
||||
return byExt
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -11,3 +11,14 @@ import (
|
||||
func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(r.Context(), d)
|
||||
}
|
||||
|
||||
// contextLike is the read surface store.InstanceKeys needs, so the same call works
|
||||
// from a request and from a detached background goroutine.
|
||||
type contextLike = context.Context
|
||||
|
||||
// contextWithBackgroundTimeout bounds work that has outlived its request. A push
|
||||
// round trip is started from a handler but must not be cancelled when that handler
|
||||
// returns, or the notification is dropped the moment the response is written.
|
||||
func contextWithBackgroundTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/ics"
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
"github.com/kristianwind/verdande/internal/todoist"
|
||||
)
|
||||
|
||||
// maxImportBytes caps an uploaded CSV at 10 MiB, which is far more than any real
|
||||
// Todoist export — the largest personal accounts run to a few hundred kilobytes.
|
||||
const maxImportBytes = 10 << 20
|
||||
|
||||
type importResult struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Projects int `json:"projects"`
|
||||
Tasks int `json:"tasks"`
|
||||
Sections int `json:"sections"`
|
||||
Comments int `json:"comments"`
|
||||
// Warnings are the things that could not be brought across exactly — an
|
||||
// assignee who is not a member here, a date that was natural language in a
|
||||
// language we do not parse. Reported rather than silently dropped, because a
|
||||
// silent partial import is discovered weeks later.
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"the file is too large")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
rows, err := todoist.Parse(file)
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
// Todoist names the file after the project, which is the best guess
|
||||
// available when the form did not say.
|
||||
name = strings.TrimSuffix(safeFilename(header.Filename), ".csv")
|
||||
}
|
||||
if name == "" {
|
||||
name = "Importeret"
|
||||
}
|
||||
|
||||
project := todoist.FromRows(rows, name)
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// importProject writes a parsed project into the database.
|
||||
//
|
||||
// Tasks are created one at a time rather than in a single transaction: an import of
|
||||
// two thousand tasks that fails on the last one would otherwise leave nothing, and
|
||||
// a partial import that says what it managed is far more useful than an empty
|
||||
// project and an error.
|
||||
func (s *Server) importProject(r *http.Request, user *store.User, p todoist.Project) (*importResult, error) {
|
||||
ctx := r.Context()
|
||||
result := &importResult{Warnings: []string{}}
|
||||
|
||||
project := &store.Project{Name: p.Name, OwnerID: user.ID}
|
||||
if err := s.db.CreateProject(ctx, project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.ProjectID = project.ID
|
||||
result.Projects = 1
|
||||
|
||||
loc := userLocation(user.Timezone)
|
||||
|
||||
var addTask func(t todoist.Task, sectionID, parentID string) error
|
||||
addTask = func(t todoist.Task, sectionID, parentID string) error {
|
||||
task := &store.Task{
|
||||
ProjectID: project.ID, SectionID: sectionID, ParentID: parentID,
|
||||
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
||||
CreatedBy: user.ID,
|
||||
}
|
||||
|
||||
if t.Date != "" {
|
||||
if date, remaining, ok := todoist.ParseDate(t.Date); ok {
|
||||
task.DueDate = date
|
||||
} else if remaining != "" {
|
||||
// Todoist stores what somebody typed, so most dates are natural
|
||||
// language. The quick-add parser already understands a great deal
|
||||
// of it, in both languages — and a recurrence phrase like
|
||||
// "every Monday" comes across as a real repeating task.
|
||||
parsed := quickadd.Parse(remaining, time.Now().In(loc), user.Locale)
|
||||
task.DueDate = parsed.DueDate
|
||||
task.RecurrenceRule = parsed.Recurrence
|
||||
if task.DueDate == "" && task.RecurrenceRule == "" {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("%q: datoen %q kunne ikke læses", t.Content, t.Date))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Tasks++
|
||||
|
||||
if t.Assignee != "" {
|
||||
result.Warnings = append(result.Warnings,
|
||||
fmt.Sprintf("%q var tildelt %q, som ikke er medlem her", t.Content, t.Assignee))
|
||||
}
|
||||
|
||||
for _, note := range t.Comments {
|
||||
if _, err := s.db.CreateComment(ctx, task.ID, user.ID, note); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Comments++
|
||||
}
|
||||
for _, child := range t.Children {
|
||||
if err := addTask(child, sectionID, task.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, t := range p.Tasks {
|
||||
if err := addTask(t, "", ""); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, section := range p.Sections {
|
||||
s3 := &store.Section{ProjectID: project.ID, Name: section.Name}
|
||||
if err := s.db.CreateSection(ctx, s3); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Sections++
|
||||
for _, t := range section.Tasks {
|
||||
if err := addTask(t, s3.ID, ""); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.activity(r, project.ID, "", "project.imported", map[string]any{
|
||||
"name": project.Name, "tasks": result.Tasks,
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- generic CSV -------------------------------------------------------------------
|
||||
|
||||
type genericImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
// Mapping says which column holds what: {"content": "Opgave", "due": "Frist"}.
|
||||
// Supplied by the mapping UI after it has shown the file's own headers.
|
||||
Mapping map[string]string `json:"mapping"`
|
||||
Rows []map[string]string `json:"rows"`
|
||||
}
|
||||
|
||||
// handleImportCSV takes a file that is not a Todoist export at all, with the caller
|
||||
// saying which column means what.
|
||||
//
|
||||
// The parsing happens in the browser and the mapped rows arrive as JSON. That keeps
|
||||
// the column-mapping interface — which needs to show a preview and let somebody
|
||||
// change their mind — from requiring the file to be uploaded twice.
|
||||
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
var req genericImportRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
contentColumn := req.Mapping["content"]
|
||||
if contentColumn == "" {
|
||||
writeFieldErrors(w, map[string]string{"mapping": "der skal peges på en kolonne med opgaveteksten"})
|
||||
return
|
||||
}
|
||||
if len(req.Rows) == 0 {
|
||||
writeFieldErrors(w, map[string]string{"rows": "der er ingen rækker"})
|
||||
return
|
||||
}
|
||||
if len(req.Rows) > 5000 {
|
||||
writeFieldErrors(w, map[string]string{"rows": "højst 5000 rækker ad gangen"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "Importeret"
|
||||
}
|
||||
|
||||
project := todoist.Project{Name: name}
|
||||
for _, row := range req.Rows {
|
||||
content := strings.TrimSpace(row[contentColumn])
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
task := todoist.Task{
|
||||
Content: content,
|
||||
Description: row[req.Mapping["description"]],
|
||||
Date: row[req.Mapping["due"]],
|
||||
Priority: 4,
|
||||
}
|
||||
if col := req.Mapping["priority"]; col != "" {
|
||||
// Whatever the source called it, 1 through 4 is read as verdande's own
|
||||
// numbering — an arbitrary foreign scale cannot be guessed at.
|
||||
switch strings.TrimSpace(row[col]) {
|
||||
case "1":
|
||||
task.Priority = 1
|
||||
case "2":
|
||||
task.Priority = 2
|
||||
case "3":
|
||||
task.Priority = 3
|
||||
}
|
||||
}
|
||||
project.Tasks = append(project.Tasks, task)
|
||||
}
|
||||
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import csv", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// --- export --------------------------------------------------------------------------
|
||||
|
||||
// handleExportProject writes one project as a Todoist-compatible CSV.
|
||||
func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
return
|
||||
}
|
||||
exported, err := s.buildExport(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "export project", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+strings.TrimSuffix(ics.Filename(project.Name), ".ics")+`.csv"`)
|
||||
if err := todoist.Write(w, todoist.ToRows(exported)); err != nil {
|
||||
s.log.Error("write export", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildExport turns a stored project back into the neutral shape the CSV writer
|
||||
// takes, rebuilding the task tree from parent ids.
|
||||
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
|
||||
ctx := r.Context()
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{project.ID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
return todoist.Project{}, err
|
||||
}
|
||||
sections, err := s.db.ListSections(ctx, project.ID)
|
||||
if err != nil {
|
||||
return todoist.Project{}, err
|
||||
}
|
||||
|
||||
// Children indexed by parent, so the tree can be built in one pass.
|
||||
children := map[string][]store.Task{}
|
||||
for _, t := range tasks {
|
||||
if t.ParentID != "" {
|
||||
children[t.ParentID] = append(children[t.ParentID], t)
|
||||
}
|
||||
}
|
||||
|
||||
var build func(t store.Task) todoist.Task
|
||||
build = func(t store.Task) todoist.Task {
|
||||
out := todoist.Task{
|
||||
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
||||
Date: t.DueDate, Author: user.Name,
|
||||
}
|
||||
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
|
||||
for _, c := range comments {
|
||||
out.Comments = append(out.Comments, c.Body)
|
||||
}
|
||||
}
|
||||
for _, child := range children[t.ID] {
|
||||
out.Children = append(out.Children, build(child))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
exported := todoist.Project{Name: project.Name}
|
||||
bySection := map[string][]todoist.Task{}
|
||||
for _, t := range tasks {
|
||||
if t.ParentID != "" {
|
||||
continue // reached through its parent
|
||||
}
|
||||
built := build(t)
|
||||
if t.SectionID == "" {
|
||||
exported.Tasks = append(exported.Tasks, built)
|
||||
} else {
|
||||
bySection[t.SectionID] = append(bySection[t.SectionID], built)
|
||||
}
|
||||
}
|
||||
for _, section := range sections {
|
||||
exported.Sections = append(exported.Sections, todoist.Section{
|
||||
Name: section.Name, Tasks: bySection[section.ID],
|
||||
})
|
||||
}
|
||||
return exported, nil
|
||||
}
|
||||
|
||||
// handleExportAccount writes everything: every project, task, comment and label, as
|
||||
// JSON.
|
||||
//
|
||||
// This is the "you can leave" guarantee. It is deliberately the raw shape rather
|
||||
// than a tidied one — a backup that has been prettified is a backup that has lost
|
||||
// something, and this is what an importer on the other side would want.
|
||||
func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
type exportedTask struct {
|
||||
taskJSON
|
||||
Comments []commentJSON `json:"comments,omitempty"`
|
||||
}
|
||||
type exportedProject struct {
|
||||
projectJSON
|
||||
Sections []sectionJSON `json:"sections"`
|
||||
Tasks []exportedTask `json:"tasks"`
|
||||
Activity []any `json:"activity,omitempty"`
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Version int `json:"version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
User userJSON `json:"user"`
|
||||
Projects []exportedProject `json:"projects"`
|
||||
Labels []labelJSON `json:"labels"`
|
||||
Filters []filterJSON `json:"filters"`
|
||||
}{
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
User: toUserJSON(user),
|
||||
Projects: []exportedProject{},
|
||||
Labels: []labelJSON{},
|
||||
Filters: []filterJSON{},
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
ep := exportedProject{projectJSON: toProjectJSON(p), Sections: []sectionJSON{}, Tasks: []exportedTask{}}
|
||||
|
||||
if sections, err := s.db.ListSections(ctx, p.ID); err == nil {
|
||||
for _, sec := range sections {
|
||||
ep.Sections = append(ep.Sections, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||
}
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
et := exportedTask{taskJSON: toTaskJSON(t)}
|
||||
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
|
||||
for _, c := range comments {
|
||||
et.Comments = append(et.Comments, toCommentJSON(c))
|
||||
}
|
||||
}
|
||||
ep.Tasks = append(ep.Tasks, et)
|
||||
}
|
||||
|
||||
if entries, err := s.db.ListActivity(ctx, p.ID, 200); err == nil {
|
||||
for _, a := range entries {
|
||||
ep.Activity = append(ep.Activity, map[string]any{
|
||||
"event": a.Event, "user": a.UserName, "payload": a.Payload,
|
||||
"created_at": a.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
out.Projects = append(out.Projects, ep)
|
||||
}
|
||||
|
||||
if labels, err := s.db.ListLabels(ctx, user.ID); err == nil {
|
||||
for _, l := range labels {
|
||||
out.Labels = append(out.Labels, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount})
|
||||
}
|
||||
}
|
||||
if filters, err := s.db.ListFilters(ctx, user.ID); err == nil {
|
||||
for _, f := range filters {
|
||||
out.Filters = append(out.Filters, toFilterJSON(f))
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="verdande-`+time.Now().Format("2006-01-02")+`.json"`)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(out); err != nil {
|
||||
s.log.Error("write account export", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportProjectICS writes one project as a calendar file, for a one-off
|
||||
// import into something else. The subscribable feed is a different thing: this is a
|
||||
// snapshot somebody downloads.
|
||||
func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: project.Name, Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
cal.Tasks = append(cal.Tasks, ics.Task{
|
||||
ID: t.ID, Content: t.Content, Description: t.Description,
|
||||
ProjectName: project.Name, DueDate: t.DueDate, DueDatetime: t.DueDatetime,
|
||||
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
|
||||
Completed: t.Completed(), CompletedAt: t.CompletedAt,
|
||||
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+ics.Filename(project.Name)+`"`)
|
||||
w.Write([]byte(ics.Render(cal)))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/push"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
type notificationJSON struct {
|
||||
ID string `json:"id"`
|
||||
ActorName string `json:"actor_name,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Read bool `json:"read"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||
if err != nil {
|
||||
s.internal(w, "list notifications", err)
|
||||
return
|
||||
}
|
||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]notificationJSON, 0, len(list))
|
||||
for _, n := range list {
|
||||
out = append(out, notificationJSON{
|
||||
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
|
||||
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
|
||||
CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||
if err != nil {
|
||||
s.internal(w, "mark notifications read", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// notify delivers one notification through every channel the person has: stored for
|
||||
// the bell, pushed live to any open tab, and sent to their devices as Web Push.
|
||||
//
|
||||
// Never fails the request that caused it. Somebody assigning a task must not see an
|
||||
// error because a push service was slow.
|
||||
func (s *Server) notify(r *http.Request, n *store.Notification) {
|
||||
if n.UserID == "" || n.UserID == n.ActorID {
|
||||
return // nobody needs telling what they just did themselves
|
||||
}
|
||||
if err := s.db.CreateNotification(r.Context(), n); err != nil {
|
||||
s.log.Warn("create notification", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
|
||||
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
|
||||
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
// Detached from the request: a push round trip to Google or Mozilla has no
|
||||
// business holding up the response to the person who caused it.
|
||||
go s.pushToUser(n.UserID, n.Title, n.Body, n.ProjectID)
|
||||
}
|
||||
|
||||
// notifyProject tells everybody who can see a project, except whoever did it.
|
||||
func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind, title, body string) {
|
||||
members, err := s.db.ProjectMemberIDs(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.log.Warn("project members for notification", "err", err)
|
||||
return
|
||||
}
|
||||
for _, userID := range members {
|
||||
if userID == actorID {
|
||||
continue
|
||||
}
|
||||
s.notify(r, &store.Notification{
|
||||
UserID: userID, ActorID: actorID, ProjectID: projectID, TaskID: taskID,
|
||||
Kind: kind, Title: title, Body: body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pushToUser(userID, title, body, projectID string) {
|
||||
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
subs, err := s.db.ListPushSubscriptions(ctx, userID)
|
||||
if err != nil || len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
public, private, err := s.vapidKeys(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("vapid keys", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
|
||||
for _, sub := range subs {
|
||||
err := push.Send(ctx, push.Subscription{
|
||||
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
|
||||
}, payload, push.VAPID{
|
||||
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
|
||||
})
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
|
||||
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
|
||||
s.log.Warn("record push failure", "err", recErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
|
||||
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
|
||||
}
|
||||
|
||||
// --- subscription endpoints -----------------------------------------------------------
|
||||
|
||||
type pushSubscriptionRequest struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
// handlePushKey hands the browser the public half of the VAPID pair, which is what
|
||||
// PushManager.subscribe needs before it will produce a subscription at all.
|
||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
public, _, err := s.vapidKeys(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "vapid keys", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
var req pushSubscriptionRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
writeFieldErrors(w, map[string]string{"endpoint": "an incomplete subscription"})
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
|
||||
UserID: userFrom(r.Context()).ID,
|
||||
Endpoint: req.Endpoint,
|
||||
P256dh: req.Keys.P256dh,
|
||||
Auth: req.Keys.Auth,
|
||||
UserAgent: r.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "save push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
var req pushSubscriptionRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
s.internal(w, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -131,8 +131,39 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotifications)
|
||||
r.Post("/read", s.handleMarkNotificationsRead)
|
||||
r.Post("/{notificationID}/read", s.handleMarkNotificationsRead)
|
||||
})
|
||||
|
||||
r.Route("/push", func(r chi.Router) {
|
||||
r.Get("/key", s.handlePushKey)
|
||||
r.Post("/subscribe", s.handleSubscribePush)
|
||||
r.Post("/unsubscribe", s.handleUnsubscribePush)
|
||||
})
|
||||
|
||||
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
|
||||
r.Get("/", s.handleDownloadAttachment)
|
||||
r.Delete("/", s.handleDeleteAttachment)
|
||||
})
|
||||
|
||||
r.Route("/comments/{commentID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateComment)
|
||||
r.Delete("/", s.handleDeleteComment)
|
||||
})
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
@@ -200,6 +231,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/", s.handleGetTask)
|
||||
r.Patch("/", s.handleUpdateTask)
|
||||
r.Delete("/", s.handleDeleteTask)
|
||||
r.Get("/comments", s.handleListComments)
|
||||
r.Post("/comments", s.handleCreateComment)
|
||||
r.Post("/attachments", s.handleUploadAttachment)
|
||||
r.Get("/reminders", s.handleListReminders)
|
||||
r.Post("/reminders", s.handleCreateReminder)
|
||||
r.Post("/complete", s.handleCompleteTask)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1308,3 +1311,374 @@ func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
|
||||
t.Errorf("another user's template list shows %d entries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// --- comments, attachments and import/export -----------------------------------------
|
||||
|
||||
func TestCommentsAndAttachments(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "med kommentarer"})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
resp, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
|
||||
map[string]any{"body": "første kommentar"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create comment: %d %v", resp.StatusCode, comment)
|
||||
}
|
||||
if comment["user_name"] != "Kristian" {
|
||||
t.Errorf("user_name = %v", comment["user_name"])
|
||||
}
|
||||
|
||||
// A file attached directly to the task.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "bilag.pdf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write([]byte("%PDF-1.4 ikke en rigtig pdf"))
|
||||
writer.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/tasks/"+taskID+"/attachments", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
upload, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer upload.Body.Close()
|
||||
if upload.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload: status %d", upload.StatusCode)
|
||||
}
|
||||
|
||||
var attachment map[string]any
|
||||
raw, _ := io.ReadAll(upload.Body)
|
||||
json.Unmarshal(raw, &attachment)
|
||||
if attachment["filename"] != "bilag.pdf" {
|
||||
t.Errorf("filename = %v", attachment["filename"])
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
|
||||
if got, _ := list["comments"].([]any); len(got) != 1 {
|
||||
t.Errorf("got %d comments, want 1", len(got))
|
||||
}
|
||||
if got, _ := list["attachments"].([]any); len(got) != 1 {
|
||||
t.Errorf("got %d attachments on the task, want 1", len(got))
|
||||
}
|
||||
|
||||
// The file downloads, and never as something a browser would render.
|
||||
dl, dlBody := ts.do(t, "GET", "/api/v1/attachments/"+attachment["id"].(string), nil)
|
||||
_ = dlBody
|
||||
if dl.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download: status %d", dl.StatusCode)
|
||||
}
|
||||
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q; an uploaded file must never be served inline", ct)
|
||||
}
|
||||
if cd := dl.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
|
||||
t.Errorf("Content-Disposition = %q, want an attachment", cd)
|
||||
}
|
||||
}
|
||||
|
||||
// A viewer can read a discussion but not join it, and cannot upload.
|
||||
func TestViewersCannotComment(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
viewer := ts.newUser(t, "viewer@example.dk", "Viewer")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "opgave", "project_id": projectID,
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "viewer@example.dk", "role": "viewer"})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments", map[string]any{"body": "ejerens ord"})
|
||||
|
||||
if r, body := viewer.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil); r.StatusCode != http.StatusOK {
|
||||
t.Errorf("a viewer cannot read comments: %d %v", r.StatusCode, body)
|
||||
}
|
||||
if r, _ := viewer.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
|
||||
map[string]any{"body": "smuglet ind"}); r.StatusCode < 400 {
|
||||
t.Errorf("a viewer could comment: status %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's comment is theirs. A project role does not confer the right to
|
||||
// rewrite what another person said.
|
||||
func TestOnlyTheAuthorCanEditAComment(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
editor := ts.newUser(t, "editor@example.dk", "Editor")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "editor@example.dk", "role": "editor"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "opgave", "project_id": projectID,
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
_, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
|
||||
map[string]any{"body": "ejerens ord"})
|
||||
commentID := comment["id"].(string)
|
||||
|
||||
resp, _ := editor.do(t, "PATCH", "/api/v1/comments/"+commentID,
|
||||
map[string]any{"body": "omskrevet"})
|
||||
if resp.StatusCode < 400 {
|
||||
t.Errorf("an editor rewrote somebody else's comment: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
|
||||
comments, _ := list["comments"].([]any)
|
||||
if comments[0].(map[string]any)["body"] != "ejerens ord" {
|
||||
t.Error("the comment was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTodoistCSV(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
csv := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
|
||||
"task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,\n" +
|
||||
"note,En kommentar,,,,,,,,\n" +
|
||||
"task,Find bilag,,1,2,Kristian,,,,\n" +
|
||||
"section,Til gennemsyn,,,,,,,,\n" +
|
||||
"task,Gennemgå,,3,1,Kristian,,,,\n"
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
writer.WriteField("name", "Fra Todoist")
|
||||
part, err := writer.CreateFormFile("file", "firma.csv")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write([]byte(csv))
|
||||
writer.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("import: status %d, %s", resp.StatusCode, raw)
|
||||
}
|
||||
var result map[string]any
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
json.Unmarshal(raw, &result)
|
||||
|
||||
if result["tasks"] != float64(3) {
|
||||
t.Errorf("imported %v tasks, want 3", result["tasks"])
|
||||
}
|
||||
if result["sections"] != float64(1) {
|
||||
t.Errorf("imported %v sections, want 1", result["sections"])
|
||||
}
|
||||
if result["comments"] != float64(1) {
|
||||
t.Errorf("imported %v comments, want 1", result["comments"])
|
||||
}
|
||||
|
||||
projectID := result["project_id"].(string)
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
|
||||
var found bool
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["content"] != "Betal moms" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// Todoist's 4 is verdande's 1. Getting this backwards would invert the
|
||||
// urgency of every imported task.
|
||||
if task["priority"] != float64(1) {
|
||||
t.Errorf("priority = %v, want 1", task["priority"])
|
||||
}
|
||||
if task["due_date"] != "2026-03-15" {
|
||||
t.Errorf("due_date = %v", task["due_date"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the imported task was not found")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of import and export together: what goes in comes back out.
|
||||
func TestTodoistImportExportRoundTrip(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
original := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
|
||||
"task,Betal moms,husk bilag,4,1,Kristian,,,,\n" +
|
||||
"task,Find bilag,,2,2,Kristian,,,,\n" +
|
||||
"task,Ring til revisor,,1,1,Kristian,,,,\n"
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
writer.WriteField("name", "Rundtur")
|
||||
part, _ := writer.CreateFormFile("file", "firma.csv")
|
||||
part.Write([]byte(original))
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
var result map[string]any
|
||||
json.Unmarshal(raw, &result)
|
||||
projectID, _ := result["project_id"].(string)
|
||||
if projectID == "" {
|
||||
t.Fatalf("import failed: %s", raw)
|
||||
}
|
||||
|
||||
// Export it again and compare the shape.
|
||||
out, exported := ts.do(t, "GET", "/api/v1/export/projects/"+projectID+".csv", nil)
|
||||
_ = exported
|
||||
if out.StatusCode != http.StatusOK {
|
||||
t.Fatalf("export: status %d", out.StatusCode)
|
||||
}
|
||||
|
||||
// The response body was consumed by `do`; fetch it as text instead.
|
||||
req2, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/projects/"+projectID+".csv", nil)
|
||||
req2.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
csvResp, err := ts.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer csvResp.Body.Close()
|
||||
csvBytes, _ := io.ReadAll(csvResp.Body)
|
||||
|
||||
roundTripped := string(csvBytes)
|
||||
for _, want := range []string{"Betal moms", "Find bilag", "Ring til revisor", "husk bilag"} {
|
||||
if !strings.Contains(roundTripped, want) {
|
||||
t.Errorf("%q is missing from the export:\n%s", want, roundTripped)
|
||||
}
|
||||
}
|
||||
// The priorities have to come back as Todoist's numbering, not verdande's.
|
||||
if !strings.Contains(roundTripped, "task,Betal moms,husk bilag,4,1") {
|
||||
t.Errorf("the exported row does not match Todoist's format:\n%s", roundTripped)
|
||||
}
|
||||
// And the sub-task comes back exactly as it went in: Todoist priority 2 became
|
||||
// verdande's 3 on the way in and has to become 2 again on the way out.
|
||||
if !strings.Contains(roundTripped, "task,Find bilag,,2,2") {
|
||||
t.Errorf("the sub-task lost its indent or priority:\n%s", roundTripped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExport(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "en opgave", "project_id": project["id"].(string), "labels": []string{"vigtig"},
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
|
||||
map[string]any{"body": "en kommentar"})
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/account", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
resp, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("export: status %d", resp.StatusCode)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var export map[string]any
|
||||
if err := json.Unmarshal(raw, &export); err != nil {
|
||||
t.Fatalf("the export is not valid JSON: %v", err)
|
||||
}
|
||||
if export["version"] != float64(1) {
|
||||
t.Errorf("version = %v", export["version"])
|
||||
}
|
||||
|
||||
// A password hash must never be in an export somebody emails to themselves.
|
||||
if strings.Contains(string(raw), "$argon2id$") {
|
||||
t.Error("the account export contains a password hash")
|
||||
}
|
||||
|
||||
projects, _ := export["projects"].([]any)
|
||||
if len(projects) < 2 {
|
||||
t.Fatalf("got %d projects, want the Inbox and Firma", len(projects))
|
||||
}
|
||||
|
||||
var foundComment bool
|
||||
for _, p := range projects {
|
||||
tasks, _ := p.(map[string]any)["tasks"].([]any)
|
||||
for _, raw := range tasks {
|
||||
comments, _ := raw.(map[string]any)["comments"].([]any)
|
||||
if len(comments) > 0 {
|
||||
foundComment = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundComment {
|
||||
t.Error("comments are missing from the account export")
|
||||
}
|
||||
if labels, _ := export["labels"].([]any); len(labels) == 0 {
|
||||
t.Error("labels are missing from the account export")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "anden@example.dk", "role": "editor"})
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "opgave", "project_id": projectID,
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
|
||||
map[string]any{"body": "sig noget"})
|
||||
|
||||
// The other member hears about it.
|
||||
_, list := other.do(t, "GET", "/api/v1/notifications", nil)
|
||||
notifications, _ := list["notifications"].([]any)
|
||||
if len(notifications) == 0 {
|
||||
t.Fatal("the other member was not notified about the comment")
|
||||
}
|
||||
if list["unread"] == float64(0) {
|
||||
t.Error("the notification is not counted as unread")
|
||||
}
|
||||
|
||||
// The author is not told what they just wrote.
|
||||
_, own := ts.do(t, "GET", "/api/v1/notifications", nil)
|
||||
if got, _ := own["notifications"].([]any); len(got) != 0 {
|
||||
t.Errorf("the author was notified about their own comment: %v", got)
|
||||
}
|
||||
|
||||
other.do(t, "POST", "/api/v1/notifications/read", nil)
|
||||
_, after := other.do(t, "GET", "/api/v1/notifications", nil)
|
||||
if after["unread"] != float64(0) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Package push sends Web Push messages (RFC 8030) with VAPID (RFC 8292) and
|
||||
// aes128gcm payload encryption (RFC 8291).
|
||||
//
|
||||
// Written directly rather than pulled in as a dependency because the whole thing is
|
||||
// three specs' worth of well-defined crypto and about two hundred lines, and
|
||||
// because the alternative libraries carry a JWT package and a crypto stack that
|
||||
// would each be larger than this file.
|
||||
//
|
||||
// The shape of it: derive a shared secret with the browser's public key, build a
|
||||
// key and nonce from that plus both salts, encrypt the payload with AES-128-GCM,
|
||||
// and authorise the request with a signed JWT naming this server.
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
Endpoint string
|
||||
P256dh string // the browser's public key, base64url
|
||||
Auth string // the browser's auth secret, base64url
|
||||
}
|
||||
|
||||
type VAPID struct {
|
||||
Public string
|
||||
Private string
|
||||
// Subject identifies the sender to the push service, as a mailto: or https:
|
||||
// URL. Firefox rejects a request without one.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Payload is what the service worker receives.
|
||||
type Payload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// ErrGone means the subscription is dead and should be deleted rather than retried.
|
||||
var ErrGone = errors.New("push: the subscription no longer exists")
|
||||
|
||||
func IsGone(err error) bool { return errors.Is(err, ErrGone) }
|
||||
|
||||
// GenerateVAPIDKeys produces a P-256 keypair, base64url encoded.
|
||||
func GenerateVAPIDKeys() (public, private string, err error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y)
|
||||
return b64.EncodeToString(pub), b64.EncodeToString(key.D.Bytes()), nil
|
||||
}
|
||||
|
||||
// Send delivers one message to one subscription.
|
||||
func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
encrypted, err := encrypt(sub, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: encrypt: %w", err)
|
||||
}
|
||||
|
||||
token, err := signJWT(sub.Endpoint, vapid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: sign: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(encrypted))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Encoding", "aes128gcm")
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("TTL", "86400")
|
||||
// Low urgency would let a service sit on a reminder for hours.
|
||||
req.Header.Set("Urgency", "normal")
|
||||
req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound, resp.StatusCode == http.StatusGone:
|
||||
return ErrGone
|
||||
case resp.StatusCode >= 300:
|
||||
return fmt.Errorf("push: %s said %s", resp.Request.URL.Host, resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encrypt builds an RFC 8291 aes128gcm message.
|
||||
func encrypt(sub Subscription, plaintext []byte) ([]byte, error) {
|
||||
clientPubBytes, err := b64.DecodeString(sub.P256dh)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client key: %w", err)
|
||||
}
|
||||
authSecret, err := b64.DecodeString(sub.Auth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth secret: %w", err)
|
||||
}
|
||||
|
||||
curve := ecdh.P256()
|
||||
clientPub, err := curve.NewPublicKey(clientPubBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client key: %w", err)
|
||||
}
|
||||
// A fresh keypair per message: reusing one would let anybody who recovered a
|
||||
// single message key read every later one.
|
||||
serverPriv, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared, err := serverPriv.ECDH(clientPub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serverPubBytes := serverPriv.PublicKey().Bytes()
|
||||
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The key derivation is prescribed by RFC 8291 §3.3, down to the exact info
|
||||
// strings and their trailing NULs. Every byte here is load-bearing.
|
||||
prkKey := hkdf(authSecret, shared,
|
||||
append(append([]byte("WebPush: info\x00"), clientPubBytes...), serverPubBytes...), 32)
|
||||
|
||||
cek := hkdf(salt, prkKey, []byte("Content-Encoding: aes128gcm\x00"), 16)
|
||||
nonce := hkdf(salt, prkKey, []byte("Content-Encoding: nonce\x00"), 12)
|
||||
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A single record, so the padding delimiter is 0x02 ("last record") rather
|
||||
// than 0x01.
|
||||
record := append(append([]byte{}, plaintext...), 0x02)
|
||||
ciphertext := gcm.Seal(nil, nonce, record, nil)
|
||||
|
||||
// Header: salt(16) | record size(4) | key id length(1) | key id.
|
||||
var out bytes.Buffer
|
||||
out.Write(salt)
|
||||
binary.Write(&out, binary.BigEndian, uint32(4096))
|
||||
out.WriteByte(byte(len(serverPubBytes)))
|
||||
out.Write(serverPubBytes)
|
||||
out.Write(ciphertext)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// hkdf is HKDF-SHA256 for outputs of at most one hash block, which is all the sizes
|
||||
// used here. Written out rather than imported because the full generalised form
|
||||
// would be more code than this.
|
||||
func hkdf(salt, ikm, info []byte, length int) []byte {
|
||||
extract := hmac.New(sha256.New, salt)
|
||||
extract.Write(ikm)
|
||||
prk := extract.Sum(nil)
|
||||
|
||||
expand := hmac.New(sha256.New, prk)
|
||||
expand.Write(info)
|
||||
expand.Write([]byte{0x01})
|
||||
return expand.Sum(nil)[:length]
|
||||
}
|
||||
|
||||
// signJWT builds the VAPID authorisation token: an ES256 JWT whose audience is the
|
||||
// push service's origin.
|
||||
func signJWT(endpoint string, vapid VAPID) (string, error) {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
subject := vapid.Subject
|
||||
if subject == "" || strings.HasSuffix(subject, "@localhost") {
|
||||
// Firefox rejects a missing or obviously bogus subject. A generic mailto
|
||||
// is better than being refused outright on an instance with no mail set up.
|
||||
subject = "mailto:verdande@example.invalid"
|
||||
}
|
||||
|
||||
header := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`))
|
||||
claims, err := json.Marshal(map[string]any{
|
||||
"aud": u.Scheme + "://" + u.Host,
|
||||
// Twelve hours. The spec caps this at 24; shorter limits how long a
|
||||
// captured token is worth anything.
|
||||
"exp": time.Now().Add(12 * time.Hour).Unix(),
|
||||
"sub": subject,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := header + "." + b64.EncodeToString(claims)
|
||||
|
||||
privBytes, err := b64.DecodeString(vapid.Private)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := &ecdsa.PrivateKey{
|
||||
PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()},
|
||||
D: new(big.Int).SetBytes(privBytes),
|
||||
}
|
||||
key.PublicKey.X, key.PublicKey.Y = elliptic.P256().ScalarBaseMult(privBytes)
|
||||
|
||||
sum := sha256.Sum256([]byte(signingInput))
|
||||
r, sSig, err := ecdsa.Sign(rand.Reader, key, sum[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// JOSE wants the two halves as fixed-width 32-byte values, not the ASN.1
|
||||
// encoding ecdsa.Sign's other form produces — a short R silently breaks
|
||||
// verification on some services and not others.
|
||||
signature := make([]byte, 64)
|
||||
r.FillBytes(signature[:32])
|
||||
sSig.FillBytes(signature[32:])
|
||||
|
||||
return signingInput + "." + b64.EncodeToString(signature), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package todoist
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Project is the shape both sides of the conversion work in: a project with
|
||||
// sections and a tree of tasks. It is deliberately not verdande's store.Task —
|
||||
// keeping the CSV layer ignorant of the database is what lets it be tested without
|
||||
// one, and what makes the round trip a pure function.
|
||||
type Project struct {
|
||||
Name string
|
||||
Sections []Section
|
||||
Tasks []Task
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Name string
|
||||
Tasks []Task
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Content string
|
||||
Description string
|
||||
Priority int // verdande's numbering: 1 highest, 4 none
|
||||
Date string
|
||||
Assignee string
|
||||
Author string
|
||||
Comments []string
|
||||
Children []Task
|
||||
}
|
||||
|
||||
// ToRows flattens a project into CSV rows.
|
||||
//
|
||||
// Order is the data here — a task's parent is whichever row above it has a smaller
|
||||
// indent — so this walks the tree depth-first and never sorts.
|
||||
func ToRows(p Project) []Row {
|
||||
var rows []Row
|
||||
|
||||
appendTask := func(t Task, indent int) {}
|
||||
appendTask = func(t Task, indent int) {
|
||||
rows = append(rows, Row{
|
||||
Type: TypeTask,
|
||||
Content: t.Content,
|
||||
Description: t.Description,
|
||||
Priority: PriorityFromVerdande(t.Priority),
|
||||
Indent: indent,
|
||||
Author: t.Author,
|
||||
Responsible: t.Assignee,
|
||||
Date: t.Date,
|
||||
DateLang: dateLang(t.Date),
|
||||
Timezone: "",
|
||||
})
|
||||
// A comment belongs to the task above it, so notes go immediately after
|
||||
// their task and before any of its children.
|
||||
for _, note := range t.Comments {
|
||||
rows = append(rows, Row{Type: TypeNote, Content: note})
|
||||
}
|
||||
for _, child := range t.Children {
|
||||
// Todoist caps display nesting at four levels; deeper tasks are still
|
||||
// written, flattened to the deepest level it will show.
|
||||
next := indent + 1
|
||||
if next > 4 {
|
||||
next = 4
|
||||
}
|
||||
appendTask(child, next)
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range p.Tasks {
|
||||
appendTask(t, 1)
|
||||
}
|
||||
|
||||
for _, section := range p.Sections {
|
||||
// Todoist separates sections with a blank row; its own exports have one
|
||||
// and its importer is happier with it.
|
||||
rows = append(rows, Row{})
|
||||
rows = append(rows, Row{Type: TypeSection, Content: section.Name})
|
||||
for _, t := range section.Tasks {
|
||||
appendTask(t, 1)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// FromRows rebuilds the project from CSV rows.
|
||||
//
|
||||
// The indent stack is the whole algorithm: each task attaches to the last task seen
|
||||
// at one level shallower, and a task at indent 1 starts a new top-level branch.
|
||||
func FromRows(rows []Row, projectName string) Project {
|
||||
p := Project{Name: projectName}
|
||||
|
||||
// Pointers into the tree at each depth, so a child can be appended to whoever
|
||||
// is currently open one level up.
|
||||
var stack []*Task
|
||||
var currentSection *Section
|
||||
|
||||
// The most recently added task, which is what a note attaches to.
|
||||
var lastTask *Task
|
||||
|
||||
addTask := func(t Task, indent int) *Task {
|
||||
if indent < 1 {
|
||||
indent = 1
|
||||
}
|
||||
if indent > len(stack)+1 {
|
||||
// A file that jumps from indent 1 to indent 3 — possible after
|
||||
// hand-editing. Treat it as one level deeper than what is open,
|
||||
// rather than dropping the task.
|
||||
indent = len(stack) + 1
|
||||
}
|
||||
stack = stack[:indent-1]
|
||||
|
||||
if indent == 1 {
|
||||
if currentSection != nil {
|
||||
currentSection.Tasks = append(currentSection.Tasks, t)
|
||||
added := ¤tSection.Tasks[len(currentSection.Tasks)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
p.Tasks = append(p.Tasks, t)
|
||||
added := &p.Tasks[len(p.Tasks)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
|
||||
parent := stack[indent-2]
|
||||
parent.Children = append(parent.Children, t)
|
||||
added := &parent.Children[len(parent.Children)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Type {
|
||||
case TypeSection:
|
||||
p.Sections = append(p.Sections, Section{Name: row.Content})
|
||||
currentSection = &p.Sections[len(p.Sections)-1]
|
||||
// A new section resets the tree: nothing above it can be a parent of
|
||||
// anything below.
|
||||
stack = nil
|
||||
lastTask = nil
|
||||
|
||||
case TypeTask:
|
||||
if strings.TrimSpace(row.Content) == "" {
|
||||
continue
|
||||
}
|
||||
lastTask = addTask(Task{
|
||||
Content: row.Content,
|
||||
Description: row.Description,
|
||||
Priority: PriorityToVerdande(row.Priority),
|
||||
Date: row.Date,
|
||||
Assignee: row.Responsible,
|
||||
Author: row.Author,
|
||||
}, row.Indent)
|
||||
|
||||
case TypeNote:
|
||||
if lastTask != nil && strings.TrimSpace(row.Content) != "" {
|
||||
lastTask.Comments = append(lastTask.Comments, row.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// dateLang is the language Todoist should parse the DATE string in. Only set when
|
||||
// there is a date to parse; an empty column with a language beside it confuses its
|
||||
// importer.
|
||||
func dateLang(date string) string {
|
||||
if strings.TrimSpace(date) == "" {
|
||||
return ""
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// SortSections puts sections in a stable order for export, so two exports of an
|
||||
// unchanged project produce identical bytes.
|
||||
func SortSections(p *Project) {
|
||||
sort.SliceStable(p.Sections, func(i, j int) bool { return p.Sections[i].Name < p.Sections[j].Name })
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Package todoist reads and writes Todoist's own CSV template format.
|
||||
//
|
||||
// The format is what Todoist's "export as template" produces and what its "import
|
||||
// from template" accepts, so a file written here opens there and a file exported
|
||||
// from there opens here. That is the whole point: leaving a hosted product should
|
||||
// not mean leaving the data behind, and it should not be a one-way door either.
|
||||
//
|
||||
// The shape of a Todoist CSV is unusual and worth stating, because it explains most
|
||||
// of this file. Every row has the same eight columns, but the TYPE column decides
|
||||
// what a row *is*:
|
||||
//
|
||||
// task — a task; INDENT gives its depth, so 2 is a sub-task of the task above
|
||||
// section — a section heading; only CONTENT is meaningful
|
||||
// note — a comment on the task above it
|
||||
// "" — a blank spacer row, which Todoist emits between sections
|
||||
//
|
||||
// Nesting is therefore positional: a row's parent is the nearest preceding row with
|
||||
// a smaller indent. Reordering the rows changes the tree, which is why the writer
|
||||
// walks the tree in order rather than sorting by anything.
|
||||
package todoist
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Columns, in Todoist's order. The header has to match exactly or Todoist refuses
|
||||
// the file.
|
||||
var header = []string{
|
||||
"TYPE", "CONTENT", "DESCRIPTION", "PRIORITY", "INDENT", "AUTHOR", "RESPONSIBLE", "DATE", "DATE_LANG", "TIMEZONE",
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
Type string
|
||||
Content string
|
||||
Description string
|
||||
Priority int // Todoist's own numbering: 4 is highest, 1 is none
|
||||
Indent int
|
||||
Author string
|
||||
Responsible string
|
||||
Date string
|
||||
DateLang string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
const (
|
||||
TypeTask = "task"
|
||||
TypeSection = "section"
|
||||
TypeNote = "note"
|
||||
)
|
||||
|
||||
// Parse reads a Todoist CSV.
|
||||
//
|
||||
// It is lenient about what it accepts and strict about what it produces: a file
|
||||
// that has been through a spreadsheet — reordered columns, a BOM, semicolons
|
||||
// instead of commas, missing trailing fields — still reads, because that is what
|
||||
// actually arrives when somebody exports and then opens the file "just to look".
|
||||
func Parse(r io.Reader) ([]Row, error) {
|
||||
reader := csv.NewReader(newBOMStripper(r))
|
||||
// Rows genuinely vary in length once a spreadsheet has touched them.
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = true
|
||||
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
// Retry with semicolons: that is what Excel writes in a Danish locale, and
|
||||
// it is the single most common reason a re-saved export will not read.
|
||||
if semi, semiErr := readSemicolon(r); semiErr == nil {
|
||||
records = semi
|
||||
} else {
|
||||
return nil, fmt.Errorf("todoist: read csv: %w", err)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("todoist: the file is empty")
|
||||
}
|
||||
|
||||
index, err := mapColumns(records[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []Row
|
||||
for _, record := range records[1:] {
|
||||
get := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[i])
|
||||
}
|
||||
|
||||
row := Row{
|
||||
Type: strings.ToLower(get("TYPE")),
|
||||
Content: get("CONTENT"),
|
||||
Description: get("DESCRIPTION"),
|
||||
Author: get("AUTHOR"),
|
||||
Responsible: get("RESPONSIBLE"),
|
||||
Date: get("DATE"),
|
||||
DateLang: get("DATE_LANG"),
|
||||
Timezone: get("TIMEZONE"),
|
||||
}
|
||||
row.Priority, _ = strconv.Atoi(get("PRIORITY"))
|
||||
row.Indent, _ = strconv.Atoi(get("INDENT"))
|
||||
|
||||
// Blank spacer rows carry nothing; keeping them would produce empty tasks.
|
||||
if row.Type == "" && row.Content == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func readSemicolon(r io.Reader) ([][]string, error) {
|
||||
seeker, ok := r.(io.Seeker)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot re-read the input")
|
||||
}
|
||||
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := csv.NewReader(newBOMStripper(r))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = true
|
||||
return reader.ReadAll()
|
||||
}
|
||||
|
||||
// mapColumns finds each column by name rather than by position, so a file whose
|
||||
// columns have been reordered still reads.
|
||||
func mapColumns(headerRow []string) (map[string]int, error) {
|
||||
index := map[string]int{}
|
||||
for i, name := range headerRow {
|
||||
index[strings.ToUpper(strings.TrimSpace(strings.Trim(name, `"`)))] = i
|
||||
}
|
||||
if _, ok := index["CONTENT"]; !ok {
|
||||
return nil, fmt.Errorf("todoist: this does not look like a Todoist export — there is no CONTENT column")
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// Write emits a Todoist-compatible CSV.
|
||||
func Write(w io.Writer, rows []Row) error {
|
||||
writer := csv.NewWriter(w)
|
||||
if err := writer.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := []string{
|
||||
row.Type, row.Content, row.Description,
|
||||
// Todoist writes an empty priority for a row that is not a task, and
|
||||
// so must this: a "4" on a section makes its own importer complain.
|
||||
priorityField(row), indentField(row),
|
||||
row.Author, row.Responsible, row.Date, row.DateLang, row.Timezone,
|
||||
}
|
||||
if err := writer.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
func priorityField(row Row) string {
|
||||
if row.Type != TypeTask || row.Priority == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(row.Priority)
|
||||
}
|
||||
|
||||
func indentField(row Row) string {
|
||||
if row.Type != TypeTask {
|
||||
return ""
|
||||
}
|
||||
if row.Indent == 0 {
|
||||
return "1"
|
||||
}
|
||||
return strconv.Itoa(row.Indent)
|
||||
}
|
||||
|
||||
// --- priority ------------------------------------------------------------------
|
||||
|
||||
// Todoist numbers priorities the opposite way round to how it displays them: the
|
||||
// CSV says 4 for what the interface calls P1. verdande stores what the interface
|
||||
// means, so the two have to be converted rather than copied — and getting this
|
||||
// backwards would silently invert the urgency of every imported task.
|
||||
func PriorityToVerdande(todoist int) int {
|
||||
switch todoist {
|
||||
case 4:
|
||||
return 1
|
||||
case 3:
|
||||
return 2
|
||||
case 2:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
func PriorityFromVerdande(verdande int) int {
|
||||
switch verdande {
|
||||
case 1:
|
||||
return 4
|
||||
case 2:
|
||||
return 3
|
||||
case 3:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// --- dates ---------------------------------------------------------------------
|
||||
|
||||
// ParseDate reads the DATE column, which Todoist fills with whatever the person
|
||||
// typed — "tomorrow", "every Monday", "2026-03-15", "15 Mar" — because it stores
|
||||
// the natural-language string rather than a date.
|
||||
//
|
||||
// Only unambiguous machine formats are converted here. Anything else is handed back
|
||||
// as text for the caller's own natural-language parser, which already understands
|
||||
// far more of it and does so in the user's timezone.
|
||||
func ParseDate(value string) (date string, remaining string, ok bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
for _, layout := range []string{
|
||||
"2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05Z07:00",
|
||||
"02-01-2006", "02/01/2006", "Jan 2 2006", "2 Jan 2006",
|
||||
} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.Format("2006-01-02"), "", true
|
||||
}
|
||||
}
|
||||
return "", value, false
|
||||
}
|
||||
|
||||
// --- BOM ------------------------------------------------------------------------
|
||||
|
||||
// newBOMStripper drops a UTF-8 byte order mark. Excel writes one, and left in place
|
||||
// it becomes part of the first column's name — so "TYPE" is not found and the file
|
||||
// reads as having no recognisable columns at all.
|
||||
func newBOMStripper(r io.Reader) io.Reader {
|
||||
return &bomStripper{r: r}
|
||||
}
|
||||
|
||||
type bomStripper struct {
|
||||
r io.Reader
|
||||
checked bool
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (b *bomStripper) Read(p []byte) (int, error) {
|
||||
if !b.checked {
|
||||
b.checked = true
|
||||
head := make([]byte, 3)
|
||||
n, err := io.ReadFull(b.r, head)
|
||||
if n > 0 {
|
||||
head = head[:n]
|
||||
if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF {
|
||||
head = nil
|
||||
}
|
||||
b.buf = head
|
||||
}
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if len(b.buf) > 0 {
|
||||
n := copy(p, b.buf)
|
||||
b.buf = b.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
return b.r.Read(p)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package todoist
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A real Todoist template export, with the shapes that matter: a task tree, a
|
||||
// section, notes, priorities and dates.
|
||||
const sampleCSV = `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
|
||||
task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,
|
||||
note,Bilag ligger i Dropbox,,,,,,,,
|
||||
task,Find bilag,,3,2,Kristian,,,,
|
||||
task,Scan dem,,1,3,Kristian,,,,
|
||||
task,Ring til revisor,,1,1,Kristian,Anders,,,
|
||||
,,,,,,,,,
|
||||
section,Til gennemsyn,,,,,,,,
|
||||
task,Gennemgå kvartal,,2,1,Kristian,,,,
|
||||
task,Underopgave,,1,2,Kristian,,,,
|
||||
`
|
||||
|
||||
func TestParseReadsTheShape(t *testing.T) {
|
||||
rows, err := Parse(strings.NewReader(sampleCSV))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
// The blank spacer row is dropped; everything else survives.
|
||||
var tasks, sections, notes int
|
||||
for _, r := range rows {
|
||||
switch r.Type {
|
||||
case TypeTask:
|
||||
tasks++
|
||||
case TypeSection:
|
||||
sections++
|
||||
case TypeNote:
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if tasks != 6 {
|
||||
t.Errorf("got %d tasks, want 6", tasks)
|
||||
}
|
||||
if sections != 1 {
|
||||
t.Errorf("got %d sections, want 1", sections)
|
||||
}
|
||||
if notes != 1 {
|
||||
t.Errorf("got %d notes, want 1", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// Todoist's CSV numbers priorities the opposite way to how its interface shows
|
||||
// them. Getting this backwards silently inverts the urgency of every task.
|
||||
func TestPriorityIsInverted(t *testing.T) {
|
||||
cases := map[int]int{4: 1, 3: 2, 2: 3, 1: 4, 0: 4}
|
||||
for todoist, want := range cases {
|
||||
if got := PriorityToVerdande(todoist); got != want {
|
||||
t.Errorf("PriorityToVerdande(%d) = %d, want %d", todoist, got, want)
|
||||
}
|
||||
}
|
||||
for verdande, want := range map[int]int{1: 4, 2: 3, 3: 2, 4: 1} {
|
||||
if got := PriorityFromVerdande(verdande); got != want {
|
||||
t.Errorf("PriorityFromVerdande(%d) = %d, want %d", verdande, got, want)
|
||||
}
|
||||
}
|
||||
// And the two are inverses, which is what the round trip depends on.
|
||||
for v := 1; v <= 4; v++ {
|
||||
if got := PriorityToVerdande(PriorityFromVerdande(v)); got != v {
|
||||
t.Errorf("priority %d survived a round trip as %d", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromRowsBuildsTheTree(t *testing.T) {
|
||||
rows, err := Parse(strings.NewReader(sampleCSV))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := FromRows(rows, "Firma")
|
||||
|
||||
if p.Name != "Firma" {
|
||||
t.Errorf("Name = %q", p.Name)
|
||||
}
|
||||
if len(p.Tasks) != 2 {
|
||||
t.Fatalf("got %d top-level tasks, want 2", len(p.Tasks))
|
||||
}
|
||||
|
||||
moms := p.Tasks[0]
|
||||
if moms.Content != "Betal moms" {
|
||||
t.Fatalf("first task = %q", moms.Content)
|
||||
}
|
||||
if moms.Priority != 1 {
|
||||
t.Errorf("priority = %d, want 1 (Todoist's 4)", moms.Priority)
|
||||
}
|
||||
if moms.Date != "2026-03-15" {
|
||||
t.Errorf("date = %q", moms.Date)
|
||||
}
|
||||
if len(moms.Comments) != 1 || moms.Comments[0] != "Bilag ligger i Dropbox" {
|
||||
t.Errorf("comments = %v", moms.Comments)
|
||||
}
|
||||
|
||||
// Indent 2 is a child of the indent-1 task above it; indent 3 is a child of that.
|
||||
if len(moms.Children) != 1 {
|
||||
t.Fatalf("got %d children, want 1", len(moms.Children))
|
||||
}
|
||||
if moms.Children[0].Content != "Find bilag" {
|
||||
t.Errorf("child = %q", moms.Children[0].Content)
|
||||
}
|
||||
if len(moms.Children[0].Children) != 1 {
|
||||
t.Fatalf("got %d grandchildren, want 1", len(moms.Children[0].Children))
|
||||
}
|
||||
if moms.Children[0].Children[0].Content != "Scan dem" {
|
||||
t.Errorf("grandchild = %q", moms.Children[0].Children[0].Content)
|
||||
}
|
||||
|
||||
// The assignee survives.
|
||||
if p.Tasks[1].Assignee != "Anders" {
|
||||
t.Errorf("assignee = %q, want Anders", p.Tasks[1].Assignee)
|
||||
}
|
||||
|
||||
// A section resets the tree, and its own tasks nest inside it.
|
||||
if len(p.Sections) != 1 {
|
||||
t.Fatalf("got %d sections", len(p.Sections))
|
||||
}
|
||||
section := p.Sections[0]
|
||||
if section.Name != "Til gennemsyn" {
|
||||
t.Errorf("section name = %q", section.Name)
|
||||
}
|
||||
if len(section.Tasks) != 1 {
|
||||
t.Fatalf("the section has %d top-level tasks, want 1", len(section.Tasks))
|
||||
}
|
||||
if len(section.Tasks[0].Children) != 1 {
|
||||
t.Errorf("the section's task has %d children, want 1", len(section.Tasks[0].Children))
|
||||
}
|
||||
}
|
||||
|
||||
// The requirement from the brief, stated directly: a Todoist CSV in, and out again,
|
||||
// has to be the same thing. This is the test that stops an import from quietly
|
||||
// losing a sub-task, a comment or a priority.
|
||||
func TestRoundTripIsLossless(t *testing.T) {
|
||||
rows, err := Parse(strings.NewReader(sampleCSV))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := FromRows(rows, "Firma")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Write(&out, ToRows(project)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
// Read what was written and rebuild. The two projects must be identical.
|
||||
reparsed, err := Parse(bytes.NewReader(out.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("re-parsing our own output: %v", err)
|
||||
}
|
||||
again := FromRows(reparsed, "Firma")
|
||||
|
||||
assertSameProject(t, project, again)
|
||||
}
|
||||
|
||||
// And a second pass must produce byte-identical output, or "export" is not a
|
||||
// repeatable operation and diffing two exports is meaningless.
|
||||
func TestExportIsStable(t *testing.T) {
|
||||
rows, err := Parse(strings.NewReader(sampleCSV))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := FromRows(rows, "Firma")
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := Write(&first, ToRows(project)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reparsed, err := Parse(bytes.NewReader(first.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Write(&second, ToRows(FromRows(reparsed, "Firma"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if first.String() != second.String() {
|
||||
t.Errorf("two exports of the same project differ:\n--- first ---\n%s\n--- second ---\n%s",
|
||||
first.String(), second.String())
|
||||
}
|
||||
}
|
||||
|
||||
// What is written has to be readable by Todoist, which means the exact header and
|
||||
// no priority or indent on rows that are not tasks.
|
||||
func TestWrittenFileMatchesTodoistsFormat(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
err := Write(&out, ToRows(Project{
|
||||
Name: "Firma",
|
||||
Tasks: []Task{{Content: "En opgave", Priority: 1}},
|
||||
Sections: []Section{{
|
||||
Name: "En sektion",
|
||||
Tasks: []Task{{Content: "I sektionen", Priority: 4, Comments: []string{"en note"}}},
|
||||
}},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
|
||||
want := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE"
|
||||
if strings.TrimSpace(lines[0]) != want {
|
||||
t.Errorf("header:\n got %q\nwant %q", lines[0], want)
|
||||
}
|
||||
|
||||
for _, line := range lines[1:] {
|
||||
fields := strings.Split(line, ",")
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
// Only task rows carry a priority and an indent.
|
||||
if fields[0] != TypeTask && (fields[3] != "" || fields[4] != "") {
|
||||
t.Errorf("a %q row carries a priority or indent: %q", fields[0], line)
|
||||
}
|
||||
if fields[0] == TypeTask && fields[4] == "" {
|
||||
t.Errorf("a task row has no indent: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files arrive having been opened in a spreadsheet, which is where BOMs, semicolons
|
||||
// and reordered columns come from. Refusing those means refusing most real exports.
|
||||
func TestParseSurvivesRealWorldFiles(t *testing.T) {
|
||||
t.Run("utf-8 BOM", func(t *testing.T) {
|
||||
withBOM := "\xEF\xBB\xBF" + sampleCSV
|
||||
rows, err := Parse(strings.NewReader(withBOM))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("no rows were read")
|
||||
}
|
||||
if rows[0].Content != "Betal moms" {
|
||||
t.Errorf("first row = %+v; the BOM was probably left in the header", rows[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reordered columns", func(t *testing.T) {
|
||||
reordered := "CONTENT,TYPE,INDENT,PRIORITY,DESCRIPTION,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
|
||||
"Betal moms,task,1,4,,Kristian,,2026-03-15,en,\n"
|
||||
rows, err := Parse(strings.NewReader(reordered))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("got %d rows", len(rows))
|
||||
}
|
||||
if rows[0].Content != "Betal moms" || rows[0].Type != TypeTask || rows[0].Priority != 4 {
|
||||
t.Errorf("columns were read by position, not by name: %+v", rows[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short rows", func(t *testing.T) {
|
||||
short := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT\ntask,Kort række\n"
|
||||
rows, err := Parse(strings.NewReader(short))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Content != "Kort række" {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("danish characters and commas in content", func(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
original := Project{Tasks: []Task{
|
||||
{Content: `Køb mælk, brød og smør`, Description: "på vej hjem", Priority: 2},
|
||||
}}
|
||||
if err := Write(&out, ToRows(original)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := Parse(bytes.NewReader(out.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back := FromRows(rows, "")
|
||||
if back.Tasks[0].Content != `Køb mælk, brød og smør` {
|
||||
t.Errorf("content = %q; the comma was probably not quoted", back.Tasks[0].Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRejectsWhatIsNotAnExport(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"",
|
||||
"navn;pris\nkaffe;40\n",
|
||||
"just some text\n",
|
||||
} {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
if _, err := Parse(strings.NewReader(in)); err == nil {
|
||||
t.Error("a file that is not a Todoist export was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A hand-edited file can jump from indent 1 to indent 3. Dropping those tasks would
|
||||
// lose work silently; attaching them one level deeper than what is open does not.
|
||||
func TestBrokenIndentationDoesNotLoseTasks(t *testing.T) {
|
||||
broken := `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE
|
||||
task,Forælder,,1,1,,,,,
|
||||
task,Springer til tre,,1,3,,,,,
|
||||
task,Tilbage til en,,1,1,,,,,
|
||||
`
|
||||
rows, err := Parse(strings.NewReader(broken))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := FromRows(rows, "x")
|
||||
|
||||
if got := countTasks(p); got != 3 {
|
||||
t.Errorf("%d tasks survived, want all 3", got)
|
||||
}
|
||||
if len(p.Tasks) != 2 {
|
||||
t.Errorf("got %d top-level tasks, want 2", len(p.Tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantDate string
|
||||
wantOK bool
|
||||
}{
|
||||
{"2026-03-15", "2026-03-15", true},
|
||||
{"2026-03-15 10:00", "2026-03-15", true},
|
||||
{"15-03-2026", "2026-03-15", true},
|
||||
{"15/03/2026", "2026-03-15", true},
|
||||
// Todoist stores what was typed, so most dates are natural language and
|
||||
// belong to the caller's own parser.
|
||||
{"every Monday", "", false},
|
||||
{"tomorrow", "", false},
|
||||
{"i morgen", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
date, remaining, ok := ParseDate(tc.in)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if date != tc.wantDate {
|
||||
t.Errorf("date = %q, want %q", date, tc.wantDate)
|
||||
}
|
||||
if !ok && tc.in != "" && remaining != tc.in {
|
||||
t.Errorf("remaining = %q, want the original text back", remaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------------
|
||||
|
||||
func assertSameProject(t *testing.T, want, got Project) {
|
||||
t.Helper()
|
||||
|
||||
if len(want.Tasks) != len(got.Tasks) {
|
||||
t.Fatalf("top-level tasks: %d vs %d", len(want.Tasks), len(got.Tasks))
|
||||
}
|
||||
for i := range want.Tasks {
|
||||
assertSameTask(t, "task["+want.Tasks[i].Content+"]", want.Tasks[i], got.Tasks[i])
|
||||
}
|
||||
|
||||
if len(want.Sections) != len(got.Sections) {
|
||||
t.Fatalf("sections: %d vs %d", len(want.Sections), len(got.Sections))
|
||||
}
|
||||
for i := range want.Sections {
|
||||
if want.Sections[i].Name != got.Sections[i].Name {
|
||||
t.Errorf("section name: %q vs %q", want.Sections[i].Name, got.Sections[i].Name)
|
||||
}
|
||||
if len(want.Sections[i].Tasks) != len(got.Sections[i].Tasks) {
|
||||
t.Fatalf("section %q tasks: %d vs %d", want.Sections[i].Name,
|
||||
len(want.Sections[i].Tasks), len(got.Sections[i].Tasks))
|
||||
}
|
||||
for j := range want.Sections[i].Tasks {
|
||||
assertSameTask(t, "section "+want.Sections[i].Name,
|
||||
want.Sections[i].Tasks[j], got.Sections[i].Tasks[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSameTask(t *testing.T, where string, want, got Task) {
|
||||
t.Helper()
|
||||
|
||||
if want.Content != got.Content {
|
||||
t.Errorf("%s content: %q vs %q", where, want.Content, got.Content)
|
||||
}
|
||||
if want.Description != got.Description {
|
||||
t.Errorf("%s description: %q vs %q", where, want.Description, got.Description)
|
||||
}
|
||||
if want.Priority != got.Priority {
|
||||
t.Errorf("%s priority: %d vs %d", where, want.Priority, got.Priority)
|
||||
}
|
||||
if want.Date != got.Date {
|
||||
t.Errorf("%s date: %q vs %q", where, want.Date, got.Date)
|
||||
}
|
||||
if want.Assignee != got.Assignee {
|
||||
t.Errorf("%s assignee: %q vs %q", where, want.Assignee, got.Assignee)
|
||||
}
|
||||
if len(want.Comments) != len(got.Comments) {
|
||||
t.Errorf("%s comments: %v vs %v", where, want.Comments, got.Comments)
|
||||
} else {
|
||||
for i := range want.Comments {
|
||||
if want.Comments[i] != got.Comments[i] {
|
||||
t.Errorf("%s comment %d: %q vs %q", where, i, want.Comments[i], got.Comments[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(want.Children) != len(got.Children) {
|
||||
t.Fatalf("%s children: %d vs %d", where, len(want.Children), len(got.Children))
|
||||
}
|
||||
for i := range want.Children {
|
||||
assertSameTask(t, where+" > "+want.Children[i].Content, want.Children[i], got.Children[i])
|
||||
}
|
||||
}
|
||||
|
||||
func countTasks(p Project) int {
|
||||
var count func([]Task) int
|
||||
count = func(tasks []Task) int {
|
||||
n := 0
|
||||
for _, t := range tasks {
|
||||
n += 1 + count(t.Children)
|
||||
}
|
||||
return n
|
||||
}
|
||||
total := count(p.Tasks)
|
||||
for _, s := range p.Sections {
|
||||
total += count(s.Tasks)
|
||||
}
|
||||
return total
|
||||
}
|
||||
Reference in New Issue
Block a user