// 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 . package caldav import ( "slices" "strconv" "strings" "time" "code.vikunja.io/api/pkg/caldav" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" user2 "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "github.com/samedi/caldav-go/data" "github.com/samedi/caldav-go/errs" "xorm.io/xorm" ) // DavBasePath is the base url path const DavBasePath = `/dav/` // ProjectBasePath is the base path for all projects resources const ProjectBasePath = DavBasePath + `projects` // VikunjaCaldavProjectStorage represents a project storage type VikunjaCaldavProjectStorage struct { // Used when handling a project project *models.ProjectWithTasksAndBuckets // Used when handling a single task, like updating task *models.Task // The current user user *user2.User isPrincipal bool isEntry bool // Entry level handling should only return a link to the principal url } // GetResources returns either all projects, links to the principal, or only one project, depending on the request func (vcls *VikunjaCaldavProjectStorage) GetResources(rpath string, _ bool) ([]data.Resource, error) { // It looks like we need to have the same handler for returning both the calendar home set and the user principal // Since the client seems to ignore the whatever is being returned in the first request and just makes a second one // to the same url but requesting the calendar home instead // The problem with this is caldav-go just return whatever ressource it gets and making that the requested path // And for us here, there is no easy (I can think of at least one hacky way) to figure out if the client is requesting // the home or principal url. Ough. // Ok, maybe the problem is more the client making a request to /dav/ and getting a response which says // something like "hey, for /dav/projects, the calendar home is /dav/projects", but the client expects a // response to go something like "hey, for /dav/, the calendar home is /dav/projects" since it requested /dav/ // and not /dav/projects. I'm not sure if thats a bug in the client or in caldav-go. if vcls.isEntry { r := data.NewResource(rpath, &VikunjaProjectResourceAdapter{ isPrincipal: true, isCollection: true, }) return []data.Resource{r}, nil } // If the request wants the principal url, we'll return that and nothing else if vcls.isPrincipal { r := data.NewResource(DavBasePath+`/projects/`, &VikunjaProjectResourceAdapter{ isPrincipal: true, isCollection: true, }) return []data.Resource{r}, nil } // If vcls.project.ID is != 0, this means the user is doing a PROPFIND request to /projects/:project // Which means we need to get only one project if vcls.project != nil && vcls.project.ID != 0 { rr, err := vcls.getProjectRessource(true) if err != nil { return nil, err } r := data.NewResource(rpath, &rr) r.Name = vcls.project.Title return []data.Resource{r}, nil } s := db.NewSession() defer s.Close() // Otherwise get all projects theprojects, _, _, err := vcls.project.ReadAll(s, vcls.user, "", -1, 50) if err != nil { _ = s.Rollback() return nil, err } if err := s.Commit(); err != nil { return nil, err } projects := theprojects.([]*models.Project) var resources []data.Resource for _, l := range projects { rr := VikunjaProjectResourceAdapter{ project: &models.ProjectWithTasksAndBuckets{ Project: *l, }, isCollection: true, } r := data.NewResource(ProjectBasePath+"/"+strconv.FormatInt(l.ID, 10), &rr) r.Name = l.Title resources = append(resources, r) } return resources, nil } // GetResourcesByList fetches a project of resources from a slice of paths func (vcls *VikunjaCaldavProjectStorage) GetResourcesByList(rpaths []string) ([]data.Resource, error) { // Parse the set of resourcepaths into usable uids // A path looks like this: /dav/projects/10/a6eb526d5748a5c499da202fe74f36ed1aea2aef.ics // So we split the url in parts, take the last one and strip the ".ics" at the end var uids []string for _, path := range rpaths { parts := strings.Split(path, "/") uids = append(uids, strings.TrimSuffix(parts[4], ".ics")) } s := db.NewSession() defer s.Close() // GetTasksByUIDs... // Parse these into ressources... tasks, err := models.GetTasksByUIDs(s, uids, vcls.user) if err != nil { _ = s.Rollback() return nil, err } if err := s.Commit(); err != nil { return nil, err } var resources []data.Resource for _, t := range tasks { rr := VikunjaProjectResourceAdapter{ task: t, } r := data.NewResource(getTaskURL(t), &rr) r.Name = t.Title resources = append(resources, r) } return resources, nil } // GetResourcesByFilters fetches a project of resources with a filter func (vcls *VikunjaCaldavProjectStorage) GetResourcesByFilters(rpath string, _ *data.ResourceFilter) ([]data.Resource, error) { // If we already have a project saved, that means the user is making a REPORT request to find out if // anything changed, in that case we need to return all tasks. // That project is coming from a previous "getProjectRessource" in L177 if vcls.project.Tasks != nil { var resources []data.Resource for i := range vcls.project.Tasks { rr := VikunjaProjectResourceAdapter{ project: vcls.project, task: &vcls.project.Tasks[i].Task, isCollection: false, } r := data.NewResource(getTaskURL(&vcls.project.Tasks[i].Task), &rr) r.Name = vcls.project.Tasks[i].Title resources = append(resources, r) } return resources, nil } // This is used to get all rr, err := vcls.getProjectRessource(false) if err != nil { return nil, err } r := data.NewResource(rpath, &rr) r.Name = vcls.project.Title return []data.Resource{r}, nil // For now, filtering is disabled. // return vcls.GetResources(rpath, false) } func getTaskURL(task *models.Task) string { return ProjectBasePath + "/" + strconv.FormatInt(task.ProjectID, 10) + `/` + task.UID + `.ics` } // GetResource fetches a single resource func (vcls *VikunjaCaldavProjectStorage) GetResource(rpath string) (*data.Resource, bool, error) { // If the task is not nil, we need to get the task and not the project if vcls.task != nil { s := db.NewSession() defer s.Close() // save and override the updated unix date to not break any later etag checks updated := vcls.task.Updated tasks, err := models.GetTasksByUIDs(s, []string{vcls.task.UID}, vcls.user) if err != nil { _ = s.Rollback() if models.IsErrTaskDoesNotExist(err) { return nil, false, errs.ResourceNotFoundError } return nil, false, err } if err := s.Commit(); err != nil { return nil, false, err } if len(tasks) < 1 { return nil, false, errs.ResourceNotFoundError } vcls.task = tasks[0] if updated.Unix() > 0 { vcls.task.Updated = updated } rr := VikunjaProjectResourceAdapter{ project: vcls.project, task: vcls.task, } r := data.NewResource(rpath, &rr) return &r, true, nil } // Otherwise get the project with all tasks rr, err := vcls.getProjectRessource(true) if err != nil { return nil, false, err } r := data.NewResource(rpath, &rr) return &r, true, nil } // GetShallowResource gets a ressource without childs // Since Vikunja has no children, this is the same as GetResource func (vcls *VikunjaCaldavProjectStorage) GetShallowResource(rpath string) (*data.Resource, bool, error) { // Since Vikunja has no childs, this just returns the same as GetResource() // FIXME: This should just get the project with no tasks whatsoever, nothing else return vcls.GetResource(rpath) } // CreateResource creates a new resource func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (*data.Resource, error) { s := db.NewSession() defer s.Close() vTask, err := caldav.ParseTaskFromVTODO(content) if err != nil { return nil, err } vTask.ProjectID = vcls.project.ID // Check the rights canCreate, err := vTask.CanCreate(s, vcls.user) if err != nil { return nil, err } if !canCreate { return nil, errs.ForbiddenError } // Create the task err = vTask.Create(s, vcls.user) if err != nil { _ = s.Rollback() return nil, err } vcls.task.ID = vTask.ID err = persistLabels(s, vcls.user, vcls.task, vTask.Labels) if err != nil { _ = s.Rollback() return nil, err } vcls.task.ProjectID = vcls.project.ID err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks) if err != nil { _ = s.Rollback() return nil, err } if err := s.Commit(); err != nil { return nil, err } // Build up the proper response rr := VikunjaProjectResourceAdapter{ project: vcls.project, task: vTask, } r := data.NewResource(rpath, &rr) return &r, nil } // UpdateResource updates a resource func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (*data.Resource, error) { vTask, err := caldav.ParseTaskFromVTODO(content) if err != nil { return nil, err } // At this point, we already have the right task in vcls.task, so we can use that ID directly vTask.ID = vcls.task.ID // Explicitly set the ProjectID in case the task now belongs to a different project: vTask.ProjectID = vcls.project.ID vcls.task.ProjectID = vcls.project.ID s := db.NewSession() defer s.Close() // Check the rights canUpdate, err := vTask.CanUpdate(s, vcls.user) if err != nil { _ = s.Rollback() return nil, err } if !canUpdate { _ = s.Rollback() return nil, errs.ForbiddenError } // Update the task err = vTask.Update(s, vcls.user) if err != nil { _ = s.Rollback() return nil, err } err = persistLabels(s, vcls.user, vcls.task, vTask.Labels) if err != nil { _ = s.Rollback() return nil, err } err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks) if err != nil { _ = s.Rollback() return nil, err } if err := s.Commit(); err != nil { return nil, err } // Build up the proper response rr := VikunjaProjectResourceAdapter{ project: vcls.project, task: vTask, } r := data.NewResource(rpath, &rr) return &r, nil } // DeleteResource deletes a resource func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error { if vcls.task != nil { s := db.NewSession() defer s.Close() // Check the rights canDelete, err := vcls.task.CanDelete(s, vcls.user) if err != nil { _ = s.Rollback() return err } if !canDelete { return errs.ForbiddenError } // Delete it err = vcls.task.Delete(s, vcls.user) if err != nil { _ = s.Rollback() return err } return s.Commit() } return nil } func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*models.Label) (err error) { labelTitles := []string{} for _, label := range labels { labelTitles = append(labelTitles, label.Title) } u := &user2.User{ ID: a.GetID(), } // Using readall ensures the current user has the permission to see the labels they provided via caldav. existingLabels, _, _, err := models.GetLabelsByTaskIDs(s, &models.LabelByTaskIDsOptions{ Search: labelTitles, User: u, GetForUser: true, GetUnusedLabels: true, GroupByLabelIDsOnly: true, }) if err != nil { return err } labelMap := make(map[string]*models.Label) for i := range existingLabels { labelMap[existingLabels[i].Title] = &existingLabels[i].Label } for _, label := range labels { if l, has := labelMap[label.Title]; has { *label = *l continue } err = label.Create(s, a) if err != nil { return err } } // Create the label <-> task relation return task.UpdateTaskLabels(s, a, labels) } func removeStaleRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) { // Get the existing task with details: existingTask := &models.Task{ID: task.ID} // FIXME: Optimize to get only required attributes (ie. RelatedTasks). err = existingTask.ReadOne(s, a) if err != nil { return } for relationKind, relatedTasks := range existingTask.RelatedTasks { for _, relatedTask := range relatedTasks { relationInNewList := slices.ContainsFunc(newRelations[relationKind], func(newRelation *models.Task) bool { return newRelation.UID == relatedTask.UID }) if !relationInNewList { rel := models.TaskRelation{ TaskID: task.ID, OtherTaskID: relatedTask.ID, RelationKind: relationKind, } err = rel.Delete(s, a) if err != nil { return } } } } return } // Persist new relations provided by the VTODO entry: func persistRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) { err = removeStaleRelations(s, a, task, newRelations) if err != nil { return err } // Ensure the current relations exist: for relationType, relatedTasksInVTODO := range newRelations { // Persist each relation independently: for _, relatedTaskInVTODO := range relatedTasksInVTODO { var relatedTask *models.Task createDummy := false // Get the task from the DB: relatedTaskInDB, err := models.GetTaskSimpleByUUID(s, relatedTaskInVTODO.UID) if err != nil { relatedTask = relatedTaskInVTODO createDummy = true } else { relatedTask = relatedTaskInDB } // If the related task doesn't exist, create a dummy one now in the same list. // It'll probably be populated right after in a following request. // In the worst case, this was an error by the client and we are left with // this dummy task to clean up. if createDummy { relatedTask.ProjectID = task.ProjectID relatedTask.Title = "DUMMY-UID-" + relatedTask.UID err = relatedTask.Create(s, a) if err != nil { return err } } // Create the relation: rel := models.TaskRelation{ TaskID: task.ID, OtherTaskID: relatedTask.ID, RelationKind: relationType, } err = rel.Create(s, a) if err != nil && !models.IsErrRelationAlreadyExists(err) { return err } } } return err } // VikunjaProjectResourceAdapter holds the actual resource type VikunjaProjectResourceAdapter struct { project *models.ProjectWithTasksAndBuckets projectTasks []*models.TaskWithComments task *models.Task isPrincipal bool isCollection bool } // IsCollection checks if the resoure in the adapter is a collection func (vlra *VikunjaProjectResourceAdapter) IsCollection() bool { // If the discovery does not work, setting this to true makes it work again. return vlra.isCollection } // CalculateEtag returns the etag of a resource func (vlra *VikunjaProjectResourceAdapter) CalculateEtag() string { // If we're updating a task, the client sends the etag of the project instead of the one from the task. // And therefore, updating the task fails since these etags don't match. // To fix that, we use this extra field to determine if we're currently updating a task and return the // etag of the project instead. // if vlra.project != nil { // return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated, 10) + `"` // } // Return the etag of a task if we have one if vlra.task != nil { return `"` + strconv.FormatInt(vlra.task.ID, 10) + `-` + strconv.FormatInt(vlra.task.Updated.Unix(), 10) + `"` } if vlra.project == nil { return "" } // This also returns the etag of the project, and not of the task, // which becomes problematic because the client uses this etag (= the one from the project) to make // Requests to update a task. These do not match and thus updating a task fails. return `"` + strconv.FormatInt(vlra.project.ID, 10) + `-` + strconv.FormatInt(vlra.project.Updated.Unix(), 10) + `"` } // GetContent returns the content string of a resource (a task in our case) func (vlra *VikunjaProjectResourceAdapter) GetContent() string { if vlra.project != nil && vlra.project.Tasks != nil { return caldav.GetCaldavTodosForTasks(vlra.project, vlra.projectTasks) } if vlra.task != nil { project := models.ProjectWithTasksAndBuckets{Tasks: []*models.TaskWithComments{{Task: *vlra.task}}} return caldav.GetCaldavTodosForTasks(&project, project.Tasks) } return "" } // GetContentSize is the size of a caldav content func (vlra *VikunjaProjectResourceAdapter) GetContentSize() int64 { return int64(len(vlra.GetContent())) } // GetModTime returns when the resource was last modified func (vlra *VikunjaProjectResourceAdapter) GetModTime() time.Time { if vlra.task != nil { return vlra.task.Updated } if vlra.project != nil { return vlra.project.Updated } return time.Time{} } func (vcls *VikunjaCaldavProjectStorage) getProjectRessource(isCollection bool) (rr VikunjaProjectResourceAdapter, err error) { s := db.NewSession() defer s.Close() if vcls.project == nil { return } can, _, err := vcls.project.CanRead(s, vcls.user) if err != nil { _ = s.Rollback() return } if !can { _ = s.Rollback() log.Errorf("User %v tried to access a caldav resource (Project %v) which they are not allowed to access", vcls.user.Username, vcls.project.ID) return rr, models.ErrUserDoesNotHaveAccessToProject{ProjectID: vcls.project.ID, UserID: vcls.user.ID} } err = vcls.project.ReadOne(s, vcls.user) if err != nil { _ = s.Rollback() return } projectTasks := vcls.project.Tasks if projectTasks == nil { tk := models.TaskCollection{ ProjectID: vcls.project.ID, } iface, _, _, err := tk.ReadAll(s, vcls.user, "", 0, -1) if err != nil { _ = s.Rollback() return rr, err } tasks, ok := iface.([]*models.Task) if !ok { panic("Tasks returned from TaskCollection.ReadAll are not []*models.Task!") } for _, t := range tasks { projectTasks = append(projectTasks, &models.TaskWithComments{Task: *t}) } vcls.project.Tasks = projectTasks } if err := s.Commit(); err != nil { return rr, err } rr = VikunjaProjectResourceAdapter{ project: vcls.project, projectTasks: projectTasks, isCollection: isCollection, } return }