diff --git a/.agents/architecture.md b/.agents/architecture.md index 5bf21eb3..17626cec 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -56,12 +56,12 @@ Task Wizard is a self-hosted, privacy-focused task management application. It is | Layer | Directory | Purpose | |-------|-----------|---------| -| HTTP Handlers | `internal/apis/` | REST + CalDAV route handlers | +| HTTP Handlers | `internal/apis/` | REST route handlers | | Middleware | `internal/middleware/` | JWT auth, scope enforcement | | Models | `internal/models/` | GORM data models | | Repositories | `internal/repos/` | Database access layer | | Services | `internal/services/` | Business logic, scheduler, notifications, housekeeping | -| Utilities | `internal/utils/` | Auth helpers, email, CalDAV parsing, DB setup | +| Utilities | `internal/utils/` | Auth helpers, email, DB setup | | WebSocket | `internal/ws/` | Real-time push to connected clients | | Migrations | `internal/migrations/` | Schema versioning | | Config | `config/` | YAML-based configuration with env var overrides | @@ -81,7 +81,7 @@ Task Wizard is a self-hosted, privacy-focused task management application. It is - **Repository pattern** for data access abstraction - **Service layer** for business logic separation - **Dependency injection** (Uber FX) for wiring -- **Scope-based authorization** on API tokens (e.g. `task:read`, `label:write`, `dav:read`) +- **Scope-based authorization** on API tokens (e.g. `task:read`, `label:write`) - **Background scheduler** for notifications, token cleanup, password reset expiration - **Smart transport** in the frontend — uses WebSocket for real-time updates, HTTP for requests - **Feature flags** to toggle behaviors like WebSocket transport and auto-refresh diff --git a/.agents/features/api-tokens.md b/.agents/features/api-tokens.md index 9914bb27..5d19b528 100644 --- a/.agents/features/api-tokens.md +++ b/.agents/features/api-tokens.md @@ -5,7 +5,7 @@ Fine-grained access tokens that allow external integrations to interact with Tas ## Capabilities - Create named API tokens with an expiration date -- Scoped permissions: Tasks.Read, Tasks.Write, Labels.Read, Labels.Write, Dav.Read, Dav.Write, User.Read, User.Write, Tokens.Write +- Scoped permissions: Tasks.Read, Tasks.Write, Labels.Read, Labels.Write, User.Read, User.Write, Tokens.Write - Write scopes automatically include their corresponding read scope - Tokens are validated the same way as JWT sessions but carry scope restrictions - List and delete existing tokens from the settings UI diff --git a/.agents/features/caldav.md b/.agents/features/caldav.md deleted file mode 100644 index 917faf6a..00000000 --- a/.agents/features/caldav.md +++ /dev/null @@ -1,13 +0,0 @@ -# Feature: CalDAV Integration - -An authenticated CalDAV endpoint that allows third-party calendar and task apps to sync with Task Wizard. - -## Capabilities - -- CalDAV endpoint available at `/dav/tasks` -- Supports PROPFIND, REPORT, GET, PUT, and HEAD methods -- Tasks exposed in iCalendar VTODO format -- Authentication via Basic Auth using the user's email and an API token (with dav scope) as the password -- Creating and updating tasks through CalDAV clients (parses VTODO summary and due date) -- Task changes made via CalDAV are broadcast over WebSocket to connected frontends -- XML-based WebDAV response generation with proper namespace handling diff --git a/apiserver/internal/apis/caldav.go b/apiserver/internal/apis/caldav.go deleted file mode 100644 index 147be924..00000000 --- a/apiserver/internal/apis/caldav.go +++ /dev/null @@ -1,374 +0,0 @@ -package apis - -import ( - "bytes" - "context" - "encoding/xml" - "io" - "net/http" - "path" - "strconv" - "strings" - - "dkhalife.com/tasks/core/config" - authMW "dkhalife.com/tasks/core/internal/middleware/auth" - models "dkhalife.com/tasks/core/internal/models" - cRepo "dkhalife.com/tasks/core/internal/repos/caldav" - nRepo "dkhalife.com/tasks/core/internal/repos/notifier" - tRepo "dkhalife.com/tasks/core/internal/repos/task" - "dkhalife.com/tasks/core/internal/services/logging" - "dkhalife.com/tasks/core/internal/utils/auth" - "dkhalife.com/tasks/core/internal/utils/caldav" - "dkhalife.com/tasks/core/internal/ws" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -type CalDAVAPIHandler struct { - tRepo *tRepo.TaskRepository - cRepo *cRepo.CalDavRepository - nRepo *nRepo.NotificationRepository - ws *ws.WSServer - cfg *config.Config -} - -func CalDAVAPI(tRepo *tRepo.TaskRepository, cRepo *cRepo.CalDavRepository, nRepo *nRepo.NotificationRepository, wsServer *ws.WSServer, cfg *config.Config) *CalDAVAPIHandler { - return &CalDAVAPIHandler{ - tRepo: tRepo, - cRepo: cRepo, - nRepo: nRepo, - ws: wsServer, - cfg: cfg, - } -} - -func (h *CalDAVAPIHandler) handleHead(c *gin.Context) { - log := logging.FromContext(c) - log.Debugf("CalDAV HEAD request for %s", c.Request.URL.Path) - - urlPath := c.Request.URL.Path - if strings.HasSuffix(urlPath, ".ics") { - filename := path.Base(urlPath) - taskID, err := strconv.Atoi(strings.TrimSuffix(filename, ".ics")) - - if err != nil { - c.AbortWithStatus(http.StatusBadRequest) - return - } - - task, err := h.tRepo.GetTask(c, taskID) - if err != nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - - if task.CreatedBy != auth.CurrentIdentity(c).UserID { - c.AbortWithStatus(http.StatusForbidden) - return - } - } - - c.Status(http.StatusOK) -} - -func (h *CalDAVAPIHandler) handlePropfindTask(c *gin.Context, taskID int) { - log := logging.FromContext(c) - - response, taskOwner, err := h.cRepo.PropfindTask(c, taskID) - if err != nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - - currentIdentity := auth.CurrentIdentity(c) - if currentIdentity == nil || taskOwner != currentIdentity.UserID { - c.AbortWithStatus(http.StatusForbidden) - return - } - - response.Responses[0].Href = c.Request.URL.Path - - data, err := caldav.BuildXmlResponse(response) - if err != nil { - log.Errorf("Error encoding XML: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - c.Data(http.StatusOK, "application/xml; charset=utf-8", data) -} - -func (h *CalDAVAPIHandler) handlePropfind(c *gin.Context) { - log := logging.FromContext(c) - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - log.Errorf("Error reading request body: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - log.Debugf("PROPFIND request body: %s", string(body)) - - if strings.HasSuffix(c.Request.URL.Path, ".ics") { - filename := path.Base(c.Request.URL.Path) - taskID, err := strconv.Atoi(strings.TrimSuffix(filename, ".ics")) - if err != nil { - log.Infof("Invalid task ID: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - h.handlePropfindTask(c, taskID) - return - } - - currentIdentity := auth.CurrentIdentity(c) - if currentIdentity == nil { - c.AbortWithStatus(http.StatusForbidden) - return - } - - response, err := h.cRepo.PropfindUserTasks(c, currentIdentity.UserID) - if err != nil { - log.Errorf("Error getting tasks: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - data, err := caldav.BuildXmlResponse(response) - if err != nil { - log.Errorf("Error encoding XML: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - c.Data(http.StatusMultiStatus, "application/xml; charset=utf-8", data) -} - -func (h *CalDAVAPIHandler) handleGet(c *gin.Context) { - log := logging.FromContext(c) - - urlPath := c.Request.URL.Path - filename := path.Base(urlPath) - - if !strings.HasSuffix(c.Request.URL.Path, ".ics") { - c.AbortWithStatus(http.StatusMethodNotAllowed) - return - } - - log.Debugf("CalDAV GET request for %s", filename) - taskID, err := strconv.Atoi(strings.TrimSuffix(filename, ".ics")) - - if err != nil { - log.Errorf("Invalid task ID: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - vtodoContent, taskOwner, err := h.cRepo.GetTask(c, taskID) - if err != nil { - log.Errorf("Error getting task: %s", err.Error()) - c.AbortWithStatus(http.StatusNotFound) - return - } - - currentIdentity := auth.CurrentIdentity(c) - if currentIdentity == nil || taskOwner != currentIdentity.UserID { - c.AbortWithStatus(http.StatusForbidden) - return - } - - c.Data(http.StatusOK, "text/calendar; charset=utf-8", []byte(vtodoContent)) -} - -func (h *CalDAVAPIHandler) handleReport(c *gin.Context) { - log := logging.FromContext(c) - log.Debugf("CalDAV REPORT request for %s", c.Request.URL.Path) - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - log.Errorf("Error reading request body: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - log.Debugf("REPORT request body: %s", string(body)) - var report models.CalendarMultiget - err = xml.Unmarshal(body, &report) - if err != nil { - log.Errorf("Error parsing REPORT request: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - response, err := h.cRepo.MultiGet(c, report) - if err != nil { - log.Errorf("Error getting tasks: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - data, err := caldav.BuildXmlResponse(response) - if err != nil { - log.Errorf("Error encoding XML: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - c.Data(http.StatusMultiStatus, "application/xml; charset=utf-8", data) -} - -func (h *CalDAVAPIHandler) handlePut(c *gin.Context) { - log := logging.FromContext(c) - - if !strings.HasSuffix(c.Request.URL.Path, ".ics") { - c.AbortWithStatus(http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(c.Request.Body) - if err != nil { - log.Errorf("Error reading request body: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - filename := path.Base(c.Request.URL.Path) - taskID, err := strconv.Atoi(strings.TrimSuffix(filename, ".ics")) - if err != nil { - log.Infof("Invalid task ID: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - title, due, err := caldav.ParseVTODO(string(body)) - if err != nil { - log.Errorf("Error parsing VTODO: %s", err.Error()) - c.AbortWithStatus(http.StatusBadRequest) - return - } - - currentIdentity := auth.CurrentIdentity(c) - if currentIdentity == nil { - c.AbortWithStatus(http.StatusForbidden) - return - } - - task, err := h.tRepo.GetTask(c, taskID) - if err != nil { - c.AbortWithStatus(http.StatusNotFound) - return - } - - if task.CreatedBy != currentIdentity.UserID { - c.AbortWithStatus(http.StatusForbidden) - return - } - - if err := h.cRepo.UpdateTask(c, taskID, title, due); err != nil { - log.Errorf("Error updating task: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - - // Get the updated task to broadcast via WebSocket - updatedTask, err := h.tRepo.GetTask(c, taskID) - if err != nil { - log.Errorf("Error getting updated task: %s", err.Error()) - // Don't fail the request if we can't broadcast - } else { - // Regenerate notifications for the updated task - go func(task *models.Task, logger *zap.SugaredLogger) { - ctx := logging.ContextWithLogger(context.Background(), logger) - h.nRepo.GenerateNotifications(ctx, task) - }(updatedTask, log) - - // Broadcast the update to all connected clients for this user - h.ws.BroadcastToUser(currentIdentity.UserID, ws.WSResponse{ - Action: "task_updated", - Data: updatedTask, - }) - } - - c.Status(http.StatusNoContent) -} - -func (h *CalDAVAPIHandler) handleRootRedirect(c *gin.Context) { - log := logging.FromContext(c) - log.Infof("Redirecting CalDAV %s request from / to /dav/tasks/", c.Request.Method) - - // Save the request body for forwarding - var bodyBytes []byte - if c.Request.Body != nil { - var err error - bodyBytes, err = io.ReadAll(c.Request.Body) - if err != nil { - log.Errorf("Error reading request body: %s", err.Error()) - c.AbortWithStatus(http.StatusInternalServerError) - return - } - c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - } - - c.Request.URL.Path = "/dav/tasks/" - - switch c.Request.Method { - case "PROPFIND": - h.handlePropfind(c) - case "REPORT": - h.handleReport(c) - default: - c.AbortWithStatus(http.StatusMethodNotAllowed) - } -} - -func (h *CalDAVAPIHandler) handleOAuthDiscovery(c *gin.Context) { - if !h.cfg.Entra.Enabled { - c.JSON(http.StatusNotFound, gin.H{"error": "OAuth is not configured"}) - return - } - - base := "https://login.microsoftonline.com/" + h.cfg.Entra.TenantID + "/oauth2/v2.0" - c.JSON(http.StatusOK, gin.H{ - "authorization_endpoint": base + "/authorize", - "token_endpoint": base + "/token", - "client_id": h.cfg.Entra.ClientID, - "scopes": []string{ - string(models.ApiTokenScopeDavRead), - string(models.ApiTokenScopeDavWrite), - }, - }) -} - -func CalDAVRoutes(router *gin.Engine, h *CalDAVAPIHandler, auth *authMW.AuthMiddleware) { - if !h.cfg.Entra.Enabled { - return - } - - authMiddleware := auth.MiddlewareFunc() - - router.GET("/.well-known/caldav", func(c *gin.Context) { - c.Redirect(http.StatusMovedPermanently, "/dav/tasks/") - }) - - davRoutes := router.Group("dav") - davRoutes.GET("/.well-known/oauth", h.handleOAuthDiscovery) - davRoutes.Use(authMiddleware) - { - davRoutes.HEAD("/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavRead), h.handleHead) - davRoutes.Handle("PROPFIND", "/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavRead), h.handlePropfind) - davRoutes.Handle("REPORT", "/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavRead), h.handleReport) - davRoutes.GET("/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavRead), h.handleGet) - davRoutes.PUT("/tasks/*path", authMW.ScopeMiddleware(models.ApiTokenScopeDavWrite), h.handlePut) - } - - // Rewrite path before auth so WWW-Authenticate is emitted for /dav paths - davPathRewrite := func(c *gin.Context) { - c.Request.URL.Path = "/dav/tasks/" - c.Next() - } - - router.Handle("PROPFIND", "/", davPathRewrite, authMiddleware, h.handleRootRedirect) - router.Handle("REPORT", "/", davPathRewrite, authMiddleware, h.handleRootRedirect) -} diff --git a/apiserver/internal/middleware/auth/auth.go b/apiserver/internal/middleware/auth/auth.go index 327e2a33..e643e039 100644 --- a/apiserver/internal/middleware/auth/auth.go +++ b/apiserver/internal/middleware/auth/auth.go @@ -75,10 +75,6 @@ func (m *AuthMiddleware) MiddlewareFunc() gin.HandlerFunc { return func(c *gin.Context) { identity, err := m.authenticate(c) if err != nil { - if strings.HasPrefix(c.Request.URL.Path, "/dav") { - c.Header("WWW-Authenticate", m.wwwAuthenticateHeader()) - } - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": err.Error(), }) @@ -90,14 +86,6 @@ func (m *AuthMiddleware) MiddlewareFunc() gin.HandlerFunc { } } -func (m *AuthMiddleware) wwwAuthenticateHeader() string { - base := "https://login.microsoftonline.com/" + m.tenantID + "/oauth2/v2.0" - return fmt.Sprintf( - `Bearer realm="Task Wizard", authorization_uri="%s/authorize", token_uri="%s/token"`, - base, base, - ) -} - func (m *AuthMiddleware) authenticate(c *gin.Context) (*models.SignedInIdentity, error) { if !m.enabled { return m.bypassAuth(c.Request.Context()) diff --git a/apiserver/internal/models/caldav.go b/apiserver/internal/models/caldav.go deleted file mode 100644 index d0b8d958..00000000 --- a/apiserver/internal/models/caldav.go +++ /dev/null @@ -1,75 +0,0 @@ -package models - -import "encoding/xml" - -const ( - DavNamespace = "DAV:" - CalDavNamespace = "urn:ietf:params:xml:ns:caldav" - CalendarServerNamespace = "http://calendarserver.org/ns/" - SabreNamespace = "http://sabredav.org/ns" - AppleNamespace = "http://apple.com/ns/ical/" -) - -type ( - Multistatus struct { - XMLName xml.Name `xml:"d:multistatus"` - DAVAttr string `xml:"xmlns:d,attr"` - CalDAVAttr string `xml:"xmlns:cal,attr"` - CalendarServerAttr string `xml:"xmlns:cs,attr"` - SabreAttr string `xml:"xmlns:s,attr"` - AppleAttr string `xml:"xmlns:x1,attr"` - Responses []Response `xml:"d:response"` - } - - Response struct { - Href string `xml:"d:href"` - Propstat []Propstat `xml:"d:propstat"` - } - - Propstat struct { - Prop Prop `xml:"d:prop"` - Status string `xml:"d:status"` - } - - Prop struct { - ResourceType *ResourceType `xml:"d:resourcetype,omitempty"` - GetCTag string `xml:"cs:getctag,omitempty"` - SyncToken string `xml:"s:sync-token,omitempty"` - DisplayName string `xml:"d:displayname,omitempty"` - CalendarTimeZone string `xml:"cal:calendar-timezone,omitempty"` - SupportedComponents *SupportedComponents `xml:"cal:supported-calendar-component-set,omitempty"` - GetLastModified string `xml:"d:getlastmodified,omitempty"` - GetContentLength int `xml:"d:getcontentlength,omitempty"` - GetETag string `xml:"d:getetag,omitempty"` - GetContentType string `xml:"d:getcontenttype,omitempty"` - CalendarDescription string `xml:"cal:calendar-description,omitempty"` - CalendarOrder string `xml:"x1:calendar-order,omitempty"` - CalendarColor string `xml:"x1:calendar-color,omitempty"` - CalendarData string `xml:"cal:calendar-data,omitempty"` - } - - CalendarMultiget struct { - XMLName xml.Name `xml:"calendar-multiget"` - DAVAttr string `xml:"xmlns:D,attr"` - CalDAVAttr string `xml:"xmlns:C,attr"` - Prop struct { - GetETag *struct{} `xml:"D:getetag"` - CalendarData *struct{} `xml:"C:calendar-data"` - } `xml:"D:prop"` - Hrefs []string `xml:"href"` - } - - ResourceType struct { - Collection *struct{} `xml:"d:collection,omitempty"` - Calendar *struct{} `xml:"cal:calendar,omitempty"` - SharedOwner *struct{} `xml:"cs:shared-owner,omitempty"` - } - - SupportedComponents struct { - Comp []CalComp `xml:"cal:comp"` - } - - CalComp struct { - Name string `xml:"name,attr"` - } -) diff --git a/apiserver/internal/models/user.go b/apiserver/internal/models/user.go index 21b43d33..5df7d64b 100644 --- a/apiserver/internal/models/user.go +++ b/apiserver/internal/models/user.go @@ -38,8 +38,6 @@ const ( ApiTokenScopeLabelWrite ApiTokenScope = "Labels.Write" ApiTokenScopeUserRead ApiTokenScope = "User.Read" ApiTokenScopeUserWrite ApiTokenScope = "User.Write" - ApiTokenScopeDavRead ApiTokenScope = "Dav.Read" - ApiTokenScopeDavWrite ApiTokenScope = "Dav.Write" ) func AllUserScopes() []ApiTokenScope { @@ -50,7 +48,5 @@ func AllUserScopes() []ApiTokenScope { ApiTokenScopeLabelWrite, ApiTokenScopeUserRead, ApiTokenScopeUserWrite, - ApiTokenScopeDavRead, - ApiTokenScopeDavWrite, } } diff --git a/apiserver/internal/repos/caldav/caldav.go b/apiserver/internal/repos/caldav/caldav.go deleted file mode 100644 index 14c4da8b..00000000 --- a/apiserver/internal/repos/caldav/caldav.go +++ /dev/null @@ -1,311 +0,0 @@ -package caldav - -import ( - "context" - "crypto/md5" - "encoding/hex" - "fmt" - "path" - "sort" - "strconv" - "strings" - "time" - - "dkhalife.com/tasks/core/internal/models" - tRepo "dkhalife.com/tasks/core/internal/repos/task" - uRepo "dkhalife.com/tasks/core/internal/repos/user" - "dkhalife.com/tasks/core/internal/services/logging" -) - -type CalDavRepository struct { - tRepo *tRepo.TaskRepository - uRepo uRepo.IUserRepo -} - -func NewCalDavRepository(tRepo *tRepo.TaskRepository, uRepo uRepo.IUserRepo) *CalDavRepository { - return &CalDavRepository{tRepo: tRepo, uRepo: uRepo} -} - -func formatHTTPDate(t time.Time) string { - return t.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") -} - -func generateETag(task *models.Task) string { - // Calculate the latest modification timestamp, this will - // cover all cases where data might have changed in the task - // or its associated labels. - latest := task.UpdatedAt - if latest == nil { - latest = &task.CreatedAt - } - - labelIDs := make([]string, 0, len(task.Labels)) - for _, label := range task.Labels { - labelIDs = append(labelIDs, strconv.Itoa(label.ID)) - if label.UpdatedAt != nil && label.UpdatedAt.After(*latest) { - latest = label.UpdatedAt - } else if label.CreatedAt.After(*latest) { - latest = &label.CreatedAt - } - } - - base := latest.UTC().Format("20060102T150405Z") - - // Including the label ids covers the cases where association - // of labels to tasks changes without requiring a new date - // field update. - if len(labelIDs) > 0 { - sort.Strings(labelIDs) // Ensure labelIDs are sorted for consistent ETag generation - base = base + ";" + strings.Join(labelIDs, ";") - } - - hash := md5.Sum([]byte(base)) - return fmt.Sprintf("\"%s\"", hex.EncodeToString(hash[:])) -} - -func generateCategories(task *models.Task) string { - if len(task.Labels) == 0 { - return "" - } - - labels := make([]string, len(task.Labels)) - for i, label := range task.Labels { - labels[i] = label.Name - } - return strings.Join(labels, ",") -} - -func generateVTODO(task *models.Task) string { - created := task.CreatedAt.UTC().Format("20060102T150405Z") - - // Sanitize the task title for iCalendar compatibility - sanitizedTitle := strings.ReplaceAll(task.Title, "\r", "") - sanitizedTitle = strings.ReplaceAll(sanitizedTitle, "\n", "") - sanitizedTitle = strings.ReplaceAll(sanitizedTitle, "\\", "\\\\") - sanitizedTitle = strings.ReplaceAll(sanitizedTitle, ",", "\\,") - sanitizedTitle = strings.ReplaceAll(sanitizedTitle, ";", "\\;") - - var lastModified string - if task.UpdatedAt != nil { - lastModified = task.UpdatedAt.UTC().Format("20060102T150405Z") - } else { - lastModified = created - } - - dueDate := "" - if task.NextDueDate != nil { - dueDate = fmt.Sprintf("DUE:%s", task.NextDueDate.UTC().Format("20060102T150405Z")) - } - - categories := generateCategories(task) - if len(categories) > 0 { - categories = fmt.Sprintf("CATEGORIES:%s", categories) - } - - vtodo := fmt.Sprintf(`BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:%s -LAST-MODIFIED:%s -DTSTAMP:%s -UID:%d -SUMMARY:%s -%s -%s -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR`, - created, - lastModified, - lastModified, - task.ID, - sanitizedTitle, - dueDate, - categories) - - return vtodo -} - -func (r *CalDavRepository) PropfindTask(c context.Context, taskID int) (models.Multistatus, int, error) { - task, err := r.tRepo.GetTask(c, taskID) - if err != nil { - return models.Multistatus{}, -1, err - } - - etag := generateETag(task) - vtodoContent := generateVTODO(task) - contentLength := len(vtodoContent) - - response := models.Multistatus{ - DAVAttr: models.DavNamespace, - CalDAVAttr: models.CalDavNamespace, - CalendarServerAttr: models.CalendarServerNamespace, - SabreAttr: models.SabreNamespace, - AppleAttr: models.AppleNamespace, - Responses: []models.Response{ - { - Propstat: []models.Propstat{ - { - Prop: models.Prop{ - GetLastModified: formatHTTPDate(time.Now()), - GetContentLength: contentLength, - ResourceType: &models.ResourceType{}, - GetETag: etag, - GetContentType: "text/calendar; charset=utf-8; component=vtodo", - }, - Status: "HTTP/1.1 200 OK", - }, - }, - }, - }, - } - - return response, task.CreatedBy, nil -} - -func (r *CalDavRepository) PropfindUserTasks(c context.Context, userID int) (models.Multistatus, error) { - log := logging.FromContext(c) - - tasks, err := r.tRepo.GetTasks(c, userID) - if err != nil { - return models.Multistatus{}, err - } - - response := models.Multistatus{ - DAVAttr: models.DavNamespace, - CalDAVAttr: models.CalDavNamespace, - CalendarServerAttr: models.CalendarServerNamespace, - SabreAttr: models.SabreNamespace, - AppleAttr: models.AppleNamespace, - Responses: []models.Response{}, - } - - lastModified, err := r.uRepo.GetLastCreatedOrModifiedForUserResources(c, userID) - log.Debugf("Last modified for user %d: %s", userID, lastModified) - if err != nil { - return models.Multistatus{}, err - } - - response.Responses = append(response.Responses, models.Response{ - Href: "/dav/tasks/", - Propstat: []models.Propstat{ - { - Prop: models.Prop{ - ResourceType: &models.ResourceType{ - Collection: &struct{}{}, - Calendar: &struct{}{}, - SharedOwner: &struct{}{}, - }, - GetCTag: "http://sabre.io/ns/sync/" + lastModified, - SyncToken: lastModified, - SupportedComponents: &models.SupportedComponents{ - Comp: []models.CalComp{ - {Name: "VTODO"}, - }, - }, - DisplayName: "Task Wizard", - CalendarTimeZone: "UTC", - CalendarDescription: "", - CalendarOrder: "0", - CalendarColor: "", - }, - Status: "HTTP/1.1 200 OK", - }, - }, - }) - - for _, task := range tasks { - taskID := strconv.Itoa(task.ID) - filename := taskID + ".ics" - - log.Debugf("Processing task for CalDAV: ID=%d, Title=%s", task.ID, task.Title) - - etag := generateETag(task) - vtodoContent := generateVTODO(task) - contentLength := len(vtodoContent) - - response.Responses = append(response.Responses, models.Response{ - Href: "/dav/tasks/" + filename, - Propstat: []models.Propstat{ - { - Prop: models.Prop{ - GetLastModified: formatHTTPDate(time.Now()), - GetContentLength: contentLength, - ResourceType: &models.ResourceType{}, - GetETag: etag, - GetContentType: "text/calendar; charset=utf-8; component=vtodo", - }, - Status: "HTTP/1.1 200 OK", - }, - }, - }) - } - - return response, nil -} - -func (r *CalDavRepository) GetTask(c context.Context, taskID int) (string, int, error) { - task, err := r.tRepo.GetTask(c, taskID) - if err != nil { - return "", -1, err - } - - return generateVTODO(task), task.CreatedBy, nil -} - -func (r *CalDavRepository) MultiGet(c context.Context, request models.CalendarMultiget) (models.Multistatus, error) { - response := models.Multistatus{ - DAVAttr: models.DavNamespace, - CalDAVAttr: models.CalDavNamespace, - CalendarServerAttr: models.CalendarServerNamespace, - SabreAttr: models.SabreNamespace, - AppleAttr: models.AppleNamespace, - Responses: []models.Response{}, - } - - for _, href := range request.Hrefs { - filename := path.Base(href) - taskID, err := strconv.Atoi(strings.TrimSuffix(filename, ".ics")) - if err != nil { - return models.Multistatus{}, fmt.Errorf("invalid task ID: %s", err.Error()) - } - - task, err := r.tRepo.GetTask(c, taskID) - if err != nil { - return models.Multistatus{}, err - } - - etag := generateETag(task) - vtodoContent := generateVTODO(task) - - response.Responses = append(response.Responses, models.Response{ - Href: href, - Propstat: []models.Propstat{ - { - Prop: models.Prop{ - GetETag: etag, - CalendarData: vtodoContent, - GetContentType: "text/calendar; charset=utf-8; component=vtodo", - }, - Status: "HTTP/1.1 200 OK", - }, - }, - }) - } - - return response, nil -} - -func (r *CalDavRepository) UpdateTask(c context.Context, taskID int, title string, dueDate *time.Time) error { - task, err := r.tRepo.GetTask(c, taskID) - if err != nil { - return err - } - - task.Title = title - task.NextDueDate = dueDate - - return r.tRepo.UpsertTask(c, task) -} diff --git a/apiserver/internal/repos/caldav/caldav_test.go b/apiserver/internal/repos/caldav/caldav_test.go deleted file mode 100644 index 1955c0e4..00000000 --- a/apiserver/internal/repos/caldav/caldav_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package caldav - -import ( - "testing" - "time" - - "dkhalife.com/tasks/core/internal/models" - "github.com/stretchr/testify/require" -) - -func TestGenerateVTODO_TitleNewline(t *testing.T) { - task := &models.Task{ - ID: 1, - Title: "Line1\nLine2\rLine3", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Line1Line2Line3") - require.NotContains(t, vtodo, "Line1\nLine2") - require.NotContains(t, vtodo, "\r") -} - -func TestGenerateVTODO_TitleSpecialChars(t *testing.T) { - task := &models.Task{ - ID: 2, - Title: "Task;with,chars", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Task\\;with\\,chars") -} - -func TestGenerateVTODO_TitleBackslash(t *testing.T) { - task := &models.Task{ - ID: 3, - Title: "Back\\slash", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Back\\\\slash") -} - -func TestGenerateVTODO_WithDueDate(t *testing.T) { - dueDate := time.Date(2023, 3, 15, 14, 30, 0, 0, time.UTC) - task := &models.Task{ - ID: 4, - Title: "Task with due date", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - NextDueDate: &dueDate, - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Task with due date") - require.Contains(t, vtodo, "DUE:20230315T143000Z") -} - -func TestGenerateVTODO_WithoutDueDate(t *testing.T) { - task := &models.Task{ - ID: 5, - Title: "Task without due date", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - NextDueDate: nil, - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Task without due date") - require.NotContains(t, vtodo, "DUE:") -} - -func TestGenerateVTODO_DueDateUpdated(t *testing.T) { - // Test that updating a due date generates correct VTODO - dueDate := time.Date(2025, 10, 10, 10, 0, 0, 0, time.UTC) - updatedAt := time.Date(2023, 2, 1, 12, 0, 0, 0, time.UTC) - - task := &models.Task{ - ID: 6, - Title: "Task with updated due date", - CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC), - UpdatedAt: &updatedAt, - NextDueDate: &dueDate, - } - - vtodo := generateVTODO(task) - - require.Contains(t, vtodo, "SUMMARY:Task with updated due date") - require.Contains(t, vtodo, "DUE:20251010T100000Z") - require.Contains(t, vtodo, "LAST-MODIFIED:20230201T120000Z") -} diff --git a/apiserver/internal/utils/caldav/parser.go b/apiserver/internal/utils/caldav/parser.go deleted file mode 100644 index ea937ab6..00000000 --- a/apiserver/internal/utils/caldav/parser.go +++ /dev/null @@ -1,37 +0,0 @@ -package caldav - -import ( - "bufio" - "strings" - "time" -) - -// ParseVTODO extracts the summary and due date from a VTODO iCalendar item. -// It only supports very simple VTODO documents generated by this app. -func ParseVTODO(data string) (title string, due *time.Time, err error) { - scanner := bufio.NewScanner(strings.NewReader(data)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "SUMMARY") { - if idx := strings.Index(line, ":"); idx != -1 { - title = strings.TrimSpace(line[idx+1:]) - } - } - if strings.HasPrefix(line, "DUE") { - if idx := strings.Index(line, ":"); idx != -1 { - ts := strings.TrimSpace(line[idx+1:]) - if t, err2 := time.Parse("20060102T150405Z", ts); err2 == nil { - tt := t.UTC() - due = &tt - } else if t, err3 := time.Parse("20060102", ts); err3 == nil { - tt := t.UTC() - due = &tt - } - } - } - } - if err := scanner.Err(); err != nil { - return "", nil, err - } - return title, due, nil -} diff --git a/apiserver/internal/utils/caldav/parser_test.go b/apiserver/internal/utils/caldav/parser_test.go deleted file mode 100644 index 9d252213..00000000 --- a/apiserver/internal/utils/caldav/parser_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package caldav - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestParseVTODO_WithDueDateTimestamp(t *testing.T) { - vtodo := `BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:20230102T030405Z -LAST-MODIFIED:20230102T030405Z -DTSTAMP:20230102T030405Z -UID:1 -SUMMARY:Test Task -DUE:20230115T120000Z -CATEGORIES: -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "Test Task", title) - require.NotNil(t, due) - - expectedDue := time.Date(2023, 1, 15, 12, 0, 0, 0, time.UTC) - require.Equal(t, expectedDue, *due) -} - -func TestParseVTODO_WithDueDateOnly(t *testing.T) { - vtodo := `BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:20230102T030405Z -LAST-MODIFIED:20230102T030405Z -DTSTAMP:20230102T030405Z -UID:2 -SUMMARY:Test Task Date Only -DUE:20230116 -CATEGORIES: -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "Test Task Date Only", title) - require.NotNil(t, due) - - expectedDue := time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC) - require.Equal(t, expectedDue, *due) -} - -func TestParseVTODO_WithoutDueDate(t *testing.T) { - vtodo := `BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:20230102T030405Z -LAST-MODIFIED:20230102T030405Z -DTSTAMP:20230102T030405Z -UID:3 -SUMMARY:Test Task No Due Date -CATEGORIES: -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "Test Task No Due Date", title) - require.Nil(t, due) -} - -func TestParseVTODO_UpdateDueDate(t *testing.T) { - // Simulate updating a task with a new due date - vtodo := `BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:20230102T030405Z -LAST-MODIFIED:20230102T030405Z -DTSTAMP:20230102T030405Z -UID:1 -SUMMARY:Updated Task Title -DUE:20250315T140000Z -CATEGORIES: -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "Updated Task Title", title) - require.NotNil(t, due) - - expectedDue := time.Date(2025, 3, 15, 14, 0, 0, 0, time.UTC) - require.Equal(t, expectedDue, *due) -} - -func TestParseVTODO_RemoveDueDate(t *testing.T) { - // Simulate updating a task to remove the due date - vtodo := `BEGIN:VCALENDAR -PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN -VERSION:2.0 -BEGIN:VTODO -CREATED:20230102T030405Z -LAST-MODIFIED:20230102T030405Z -DTSTAMP:20230102T030405Z -UID:1 -SUMMARY:Task Without Due Date -CATEGORIES: -PERCENT-COMPLETE:0 -X-MOZ-GENERATION:1 -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "Task Without Due Date", title) - require.Nil(t, due) -} - -func TestParseVTODO_EmptyVTODO(t *testing.T) { - vtodo := `BEGIN:VCALENDAR -VERSION:2.0 -BEGIN:VTODO -END:VTODO -END:VCALENDAR` - - title, due, err := ParseVTODO(vtodo) - - require.NoError(t, err) - require.Equal(t, "", title) - require.Nil(t, due) -} diff --git a/apiserver/internal/utils/caldav/xml.go b/apiserver/internal/utils/caldav/xml.go deleted file mode 100644 index 68df9103..00000000 --- a/apiserver/internal/utils/caldav/xml.go +++ /dev/null @@ -1,15 +0,0 @@ -package caldav - -import ( - "bytes" - "encoding/xml" -) - -func BuildXmlResponse(response any) ([]byte, error) { - buf := &bytes.Buffer{} - buf.WriteString(xml.Header) - enc := xml.NewEncoder(buf) - - err := enc.Encode(response) - return buf.Bytes(), err -} diff --git a/apiserver/main.go b/apiserver/main.go index 2704037e..d7482af3 100644 --- a/apiserver/main.go +++ b/apiserver/main.go @@ -24,7 +24,6 @@ import ( "gorm.io/gorm" apis "dkhalife.com/tasks/core/internal/apis" - cRepo "dkhalife.com/tasks/core/internal/repos/caldav" lRepo "dkhalife.com/tasks/core/internal/repos/label" nRepo "dkhalife.com/tasks/core/internal/repos/notifier" tRepo "dkhalife.com/tasks/core/internal/repos/task" @@ -79,8 +78,6 @@ func main() { fx.Provide(ws.NewWSServer), fx.Provide(scheduler.NewScheduler), - // Labels: - fx.Provide(cRepo.NewCalDavRepository), fx.Provide(lRepo.NewLabelRepository), fx.Provide(lService.NewLabelService), fx.Provide(lService.NewLabelsMessageHandler), @@ -90,7 +87,6 @@ func main() { fx.Provide(tService.NewTasksMessageHandler), fx.Provide(apis.LabelsAPI), fx.Provide(apis.LogsAPI), - fx.Provide(apis.CalDAVAPI), fx.Provide(frontend.NewHandler), fx.Provide(backend.NewHandler), @@ -99,7 +95,6 @@ func main() { apis.TaskRoutes, apis.UserRoutes, apis.LabelRoutes, - apis.CalDAVRoutes, apis.LogRoutes, ws.Routes, tService.TaskMessages,