291 lines
12 KiB
Go
291 lines
12 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public Licensee as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public Licensee for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public Licensee
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package caldav
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"code.vikunja.io/api/pkg/db"
|
|
"code.vikunja.io/api/pkg/log"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/user"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
func getBasicAuthUserFromContext(c echo.Context) (*user.User, error) {
|
|
u, is := c.Get("userBasicAuth").(*user.User)
|
|
if !is {
|
|
return nil, fmt.Errorf("user is not user.User element, is %s", reflect.TypeOf(c.Get("userBasicAuth")))
|
|
}
|
|
if u == nil {
|
|
return nil, fmt.Errorf("userBasicAuth from context is nil")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// ProjectHandler handles requests related to project collections and individual project resources (calendars).
|
|
func ProjectHandler(c echo.Context) error {
|
|
u, err := getBasicAuthUserFromContext(c)
|
|
if err != nil {
|
|
log.Errorf("Error getting user from basic auth context: %v", err)
|
|
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
|
}
|
|
|
|
projectIDParam := c.Param("project")
|
|
|
|
// Log CalDAV request details
|
|
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
|
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body for further processing if any
|
|
log.Debugf("[CALDAV] ProjectHandler: Method=%s, Path=%s, ProjectIDParam=%s, User=%s, Headers=%v, Body=%s",
|
|
c.Request().Method, c.Path(), projectIDParam, u.Username, c.Request().Header, string(bodyBytes))
|
|
|
|
|
|
if projectIDParam == "" {
|
|
// Request to /dav/projects/ or /dav/projects
|
|
// This should list all accessible calendars (projects) for the user.
|
|
switch c.Request().Method {
|
|
case "PROPFIND":
|
|
return ListCalendars(c, u)
|
|
case "REPORT":
|
|
// CalDAV sync clients might send REPORT to the calendar collection.
|
|
// For now, we can treat it like a PROPFIND or return not implemented.
|
|
// Depending on the REPORT body, it could be a sync-collection report.
|
|
// Let's assume for now it's asking for a list of resources.
|
|
log.Debugf("[CALDAV] ProjectHandler (collection) received REPORT, treating as PROPFIND for now.")
|
|
return ListCalendars(c, u)
|
|
default:
|
|
log.Warningf("[CALDAV] ProjectHandler (collection) received unhandled method %s", c.Request().Method)
|
|
c.Response().Header().Set("Allow", "PROPFIND, REPORT, OPTIONS")
|
|
return c.NoContent(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// Request to /dav/projects/:projectid/
|
|
projectID, err := strconv.ParseInt(projectIDParam, 10, 64)
|
|
if err != nil {
|
|
log.Errorf("Invalid project ID parameter '%s': %v", projectIDParam, err)
|
|
return c.String(http.StatusBadRequest, "Invalid project ID")
|
|
}
|
|
|
|
// Verify project existence and user access (basic check, ListTasksInProject will do more)
|
|
s := db.NewSession()
|
|
defer s.Close()
|
|
proj := models.Project{ID: projectID}
|
|
canRead, _, errDb := proj.CanRead(s, u)
|
|
if errDb != nil {
|
|
log.Errorf("Error checking read permission for project %d by user %d: %v", projectID, u.ID, errDb)
|
|
return c.NoContent(http.StatusInternalServerError)
|
|
}
|
|
if !canRead {
|
|
log.Warningf("User %s (ID: %d) forbidden to access project %d", u.Username, u.ID, projectID)
|
|
return c.NoContent(http.StatusForbidden)
|
|
}
|
|
if err := s.Commit(); err != nil { // commit the read-only transaction
|
|
log.Errorf("Error committing after project read check: %v", err)
|
|
// Not returning error here as it's a read check, but logging is important.
|
|
}
|
|
|
|
|
|
switch c.Request().Method {
|
|
case "PROPFIND":
|
|
return ListTasksInProject(c, u, projectID)
|
|
case "REPORT":
|
|
// REPORT on a specific calendar. This is often a calendar-multiget or calendar-query.
|
|
// The current ListTasksInProject doesn't handle specific REPORT bodies.
|
|
// For simplicity, we can treat it as a PROPFIND for all tasks in the project.
|
|
// A more advanced implementation would parse the REPORT XML.
|
|
log.Debugf("[CALDAV] ProjectHandler (specific project) received REPORT, treating as PROPFIND for tasks.")
|
|
return ListTasksInProject(c, u, projectID)
|
|
case "OPTIONS":
|
|
c.Response().Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT, DELETE") // Methods for a calendar resource
|
|
c.Response().Header().Set("DAV", "1, 3, calendar-access") // Basic DAV + CalDAV calendar access
|
|
// Add other capabilities like calendar-schedule if supported
|
|
return c.NoContent(http.StatusOK)
|
|
default:
|
|
log.Warningf("[CALDAV] ProjectHandler (specific project) received unhandled method %s for project %d", c.Request().Method, projectID)
|
|
c.Response().Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT, DELETE") // Set Allow for unhandled methods too
|
|
return c.NoContent(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// TaskHandler manages operations on individual task resources (.ics files).
|
|
func TaskHandler(c echo.Context) error {
|
|
u, err := getBasicAuthUserFromContext(c)
|
|
if err != nil {
|
|
log.Errorf("Error getting user from basic auth context: %v", err)
|
|
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
|
}
|
|
|
|
projectIDParam := c.Param("project")
|
|
taskUIDParam := c.Param("task") // Includes .ics suffix
|
|
|
|
// Log CalDAV request details
|
|
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
|
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore body
|
|
log.Debugf("[CALDAV] TaskHandler: Method=%s, Path=%s, ProjectIDParam=%s, TaskUIDParam=%s, User=%s, Headers=%v, Body=%s",
|
|
c.Request().Method, c.Path(), projectIDParam, taskUIDParam, u.Username, c.Request().Header, string(bodyBytes))
|
|
|
|
projectID, err := strconv.ParseInt(projectIDParam, 10, 64)
|
|
if err != nil {
|
|
log.Errorf("Invalid project ID parameter '%s' in TaskHandler: %v", projectIDParam, err)
|
|
return c.String(http.StatusBadRequest, "Invalid project ID")
|
|
}
|
|
|
|
taskUID := strings.TrimSuffix(taskUIDParam, ".ics")
|
|
if taskUID == "" {
|
|
log.Errorf("Empty task UID in TaskHandler (param was '%s')", taskUIDParam)
|
|
return c.String(http.StatusBadRequest, "Invalid task UID")
|
|
}
|
|
|
|
switch c.Request().Method {
|
|
case http.MethodGet:
|
|
return FetchTaskAsICS(c, u, projectID, taskUID)
|
|
case http.MethodPut:
|
|
return UpsertTaskFromICS(c, u, projectID, taskUID)
|
|
case http.MethodDelete:
|
|
return RemoveTaskICS(c, u, projectID, taskUID)
|
|
case "PROPFIND":
|
|
// PROPFIND on a specific task. We can fetch it and return its properties.
|
|
// This is a simplified PROPFIND for a single resource.
|
|
// FetchTaskAsICS already sets ETag and ContentType headers.
|
|
// We need to wrap this in a multistatus response.
|
|
// For now, let's delegate to FetchTaskAsICS which should return 200 OK with headers.
|
|
// A full PROPFIND would require XML response.
|
|
// TODO: Implement proper PROPFIND for single task resource.
|
|
// As a temporary measure, try fetching it. If it exists, client might get headers.
|
|
// This is not fully CalDAV compliant for PROPFIND on task.
|
|
log.Debugf("[CALDAV] TaskHandler received PROPFIND for specific task, attempting simplified handling.")
|
|
return GetTaskPropertiesAsXML(c, u, projectID, taskUID)
|
|
default:
|
|
log.Warningf("[CALDAV] TaskHandler received unhandled method %s", c.Request().Method)
|
|
c.Response().Header().Set("Allow", "GET, PUT, DELETE, PROPFIND, OPTIONS")
|
|
return c.NoContent(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// PrincipalHandler handles requests to principal URLs.
|
|
// Typically /dav/principals/users/<username>/ or /.well-known/caldav
|
|
func PrincipalHandler(c echo.Context) error {
|
|
u, err := getBasicAuthUserFromContext(c)
|
|
if err != nil {
|
|
log.Errorf("Error getting user from basic auth context: %v", err)
|
|
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
|
}
|
|
|
|
// Log CalDAV request details
|
|
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
|
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
log.Debugf("[CALDAV] PrincipalHandler: Method=%s, Path=%s, User=%s, Headers=%v, Body=%s",
|
|
c.Request().Method, c.Path(), u.Username, c.Request().Header, string(bodyBytes))
|
|
|
|
switch c.Request().Method {
|
|
case "PROPFIND":
|
|
// PROPFIND on a principal URL should list properties of the principal,
|
|
// including the calendar-home-set, which points to where calendars are.
|
|
// For Vikunja, ProjectBasePath (/dav/projects/) can be considered the calendar-home-set.
|
|
// It should also list the calendars themselves (projects).
|
|
return ListPrincipalPropertiesAndCalendars(c, u)
|
|
case "REPORT":
|
|
log.Debugf("[CALDAV] PrincipalHandler received REPORT, which is not typically handled at this level. Path: %s", c.Path())
|
|
// Reports are usually on calendar or task resources.
|
|
// If it's a sync-collection on the principal, it's more complex.
|
|
return c.NoContent(http.StatusNotImplemented)
|
|
|
|
default:
|
|
log.Warningf("[CALDAV] PrincipalHandler received unhandled method %s", c.Request().Method)
|
|
// Key principal properties for PROPFIND:
|
|
// <d:current-user-principal>, <d:principal-URL>, <c:calendar-home-set>
|
|
c.Response().Header().Set("Allow", "PROPFIND, OPTIONS, REPORT")
|
|
return c.NoContent(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// EntryHandler handles requests to the CalDAV service root (e.g., /dav/ or /dav).
|
|
func EntryHandler(c echo.Context) error {
|
|
u, err := getBasicAuthUserFromContext(c)
|
|
if err != nil {
|
|
log.Errorf("Error getting user from basic auth context: %v", err)
|
|
return echo.ErrUnauthorized.SetInternal(fmt.Errorf("invalid user context: %w", err))
|
|
}
|
|
|
|
// Log CalDAV request details
|
|
bodyBytes, _ := io.ReadAll(c.Request().Body)
|
|
c.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
log.Debugf("[CALDAV] EntryHandler: Method=%s, Path=%s, User=%s, Headers=%v, Body=%s",
|
|
c.Request().Method, c.Path(), u.Username, c.Request().Header, string(bodyBytes))
|
|
|
|
switch c.Request().Method {
|
|
case "PROPFIND":
|
|
// PROPFIND on the service root should discover the user's principal
|
|
// and their calendar home set.
|
|
// For Vikunja, this essentially means listing their calendars.
|
|
return ListPrincipalPropertiesAndCalendars(c, u)
|
|
case "REPORT":
|
|
log.Debugf("[CALDAV] EntryHandler received REPORT, which is not typically handled at this level. Path: %s", c.Path())
|
|
return c.NoContent(http.StatusNotImplemented)
|
|
default:
|
|
log.Warningf("[CALDAV] EntryHandler received unhandled method %s", c.Request().Method)
|
|
c.Response().Header().Set("Allow", "PROPFIND, OPTIONS, REPORT")
|
|
return c.NoContent(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func getProjectFromParam(c echo.Context) (*models.ProjectWithTasksAndBuckets, error) {
|
|
param := c.Param("project")
|
|
if param == "" {
|
|
// This case should ideally be handled by a different route/handler (e.g., for /dav/projects/)
|
|
// If ProjectHandler gets called with no :project param, it means it's the collection.
|
|
// Returning nil, nil here and letting the caller (ProjectHandler) decide.
|
|
return nil, nil
|
|
}
|
|
|
|
s := db.NewSession()
|
|
defer s.Close()
|
|
|
|
intParam, err := strconv.ParseInt(param, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getProjectFromParam: invalid project ID '%s': %w", param, err)
|
|
}
|
|
|
|
// No need to handle FavoritesPseudoProjectID or SavedFilter here as ProjectHandler
|
|
// will use ListCalendars or ListTasksInProject which work with actual project IDs.
|
|
// CalDAV paths should resolve to actual calendar resources.
|
|
|
|
p, err := models.GetProjectSimpleByID(s, intParam)
|
|
if err != nil {
|
|
if models.IsErrProjectDoesNotExist(err) {
|
|
return nil, err // Return specific error for not found
|
|
}
|
|
return nil, fmt.Errorf("getProjectFromParam: db error getting project %d: %w", intParam, err)
|
|
}
|
|
if err := s.Commit(); err != nil {
|
|
return nil, fmt.Errorf("getProjectFromParam: error committing after GetProjectSimpleByID: %w", err)
|
|
}
|
|
|
|
|
|
// We need ProjectWithTasksAndBuckets for consistency, though only Project might be used by caller
|
|
// before calling ListTasksInProject which re-fetches with tasks.
|
|
// For basic validation, Project info is enough.
|
|
return &models.ProjectWithTasksAndBuckets{Project: *p}, nil
|
|
}
|