Better caldav support (#73)

This commit is contained in:
konrad 2019-05-22 17:48:48 +00:00 committed by Gitea
parent de24fcc2f8
commit 7107d030fc
91 changed files with 7060 additions and 323 deletions

View file

@ -26,6 +26,8 @@ steps:
- name: build
image: vikunja/golang-build:latest
pull: true
environment:
GOFLAGS: '-mod=vendor'
commands:
- make lint
- make fmt-check

View file

@ -48,10 +48,10 @@ Sorry for some of them being in German, I'll tranlate them at some point.
* [x] Pagination
* Sollte in der Config definierbar sein, wie viel pro Seite angezeigt werden soll, die CRUD-Methoden übergeben dann ein "gibt mir die Seite sowieso" an die CRUDable-Funktionenen, die müssen das dann Auswerten. Geht leider nicht anders, wenn man erst 2342352 Einträge hohlt und die dann nachträglich auf 200 begrenzt ist das ne massive Ressourcenverschwendung.
* [x] Testen, ob man über die Routen methode von echo irgendwie ein swagger spec generieren könnte -> Andere Swagger library
* [ ] CalDAV
* [x] CalDAV
* [x] Basics
* [x] Reminders
* [ ] Discovery, stichwort PROPFIND
* [x] Discovery, stichwort PROPFIND
* [x] Wir brauchen noch ne gute idee, wie man die listen kriegt, auf die man nur so Zugriff hat (ohne namespace)
* Dazu am Besten nen pseudonamespace anlegen (id -1 oder so), der hat das dann alles
* [x] Testing mit locust: https://locust.io/
@ -171,7 +171,6 @@ Sorry for some of them being in German, I'll tranlate them at some point.
* [ ] Some kind of milestones for tasks
* [ ] Create tasks from a text/markdown file (probably frontend only)
* [ ] Label-view: Get a bunch of tasks by label
* [ ] Better caldav support (VTODO)
* [ ] Debian package should have a service file
* [ ] Downloads should be served via nginx (with theme?), minio should only be used for pushing artifacts.
* [ ] User struct should have a field for the avatar url (-> gravatar md5 calculated by the backend)
@ -179,6 +178,34 @@ Sorry for some of them being in German, I'll tranlate them at some point.
-> Check if there's a way to do that efficently. Maybe only implementing it in the web handler.
* [ ] List stats to see how many tasks are done, how many are there in total, how many people have acces to a list etc
* [ ] Colors for tasks
* [ ] Better caldav support
* [x] VTODO
* [x] Fix organizer prop
* [x] Depricate the events thing for now
* [x] PROPFIND/OPTIONS : caldav discovery
* [x] Create new tasks
* [x] Save uid from the client
* [x] Update tasks
* [x] Marking as done
* [x] Fix OPTIONS Requests to the rest of the api being broken
* [x] Parse all props defined in rfc5545
* [x] COMPLETED -> Need to actually save the time the task was completed
* [x] Whenever a task ist created/updated, update the `updated` prop on the list so the etag changes and clients get notified
* [x] Fix not all tasks being displayed (My guess: Something about that f*cking etag)
* [x] Delete tasks
* [x] Last modified
* [x] Content Size
* [x] Modify the caldav lib as proposed in the pr
* [x] Improve login performance, each request taking > 1.5 sec is just too much, maybe just use the default value for hash iterations in the login/register function
* [x] Only show priority when we have one
* [x] Show a proper calendar title
* [x] Fix home principal propfind stuff
* [x] Docs
* [x] Setting to disable caldav completely
* [ ] Make it work with the app
* [ ] Cleanup the whole mess I made with the handlers and storage providers etc -> Probably a good idea to create a seperate storage provider etc for lists and tasks
* [ ] Tests
* [ ] Check if only needed things are queried from the db when accessing dav (for ex. no need to get all tasks when we act
### Refactor
@ -217,6 +244,7 @@ Sorry for some of them being in German, I'll tranlate them at some point.
### Later
* [ ] Backgrounds for lists -> needs uploading and storing and so on
* [ ] Plugins
* [ ] Rename Namespaces?
* [ ] Namespaces to collections and n-n (one list can be in multiple collections)?
@ -258,6 +286,7 @@ Sorry for some of them being in German, I'll tranlate them at some point.
* [ ] Nozbe
* [ ] Lanes
* [ ] Nirvana
* [ ] Any.do
* [ ] Good ol' Caldav (Tasks)
* [ ] More auth providers
* [ ] LDAP/AD

View file

@ -16,6 +16,8 @@ service:
# If set to true, enables a /metrics endpoint for prometheus to collect metrics about the system
# You'll need to use redis for this in order to enable common metrics over multiple nodes
enablemetrics: false
# Enable the caldav endpoint, see the docs for more details
enablecaldav: true
database:
# Database type to use. Supported types are mysql and sqlite.

View file

@ -59,6 +59,8 @@ service:
# If set to true, enables a /metrics endpoint for prometheus to collect metrics about the system
# You'll need to use redis for this in order to enable common metrics over multiple nodes
enablemetrics: false
# Enable the caldav endpoint, see the docs for more details
enablecaldav: true
database:
# Database type to use. Supported types are mysql and sqlite.

View file

@ -0,0 +1,140 @@
---
date: "2019-05-12:00:00+01:00"
title: "Caldav"
draft: false
type: "doc"
menu:
sidebar:
parent: "usage"
---
# Caldav
> **Warning:** The caldav integration is in an early alpha stage and has bugs.
> It works well with some clients while having issues with others.
> If you encounter issues, please [report them](https://code.vikunja.io/api/issues/new?body=[caldav])
Vikunja supports managing tasks via the [caldav VTODO](https://tools.ietf.org/html/rfc5545#section-3.6.2) extension.
## URLs
All urls are located under the `/dav` subspace.
Urls are:
* `/principals/<username>/`: Returns urls for list discovery. *Use this url to initially make connections to new clients.*
* `/lists/`: Used to manage lists
* `/lists/<List ID>/`: Used to manage a single list
* `/lists/<List ID>/<Task UID>`: Used to manage a task on a list
## Supported properties
Vikunja currently supports the following properties:
* `UID`
* `SUMMARY`
* `DESCRIPTION`
* `PRIORITY`
* `COMPLETED`
* `DUE`
* `DTSTART`
* `DURATION`
* `ORGANIZER`
* `RELATED-TO`
* `CREATED`
* `DTSTAMP`
* `LAST-MODIFIED`
Vikunja **currently does not** support these properties:
* `ATTACH`
* `CATEGORIES`
* `CLASS`
* `COMMENT`
* `GEO`
* `LOCATION`
* `PERCENT-COMPLETE`
* `RESOURCES`
* `STATUS`
* `CONTACT`
* `RECURRENCE-ID`
* `URL`
* Recurrence
* `SEQUENCE`
## Tested Clients
#### Working
* [Evolution](https://wiki.gnome.org/Apps/Evolution/)
#### Not working
* [Tasks (Android)](https://tasks.org/)
## Dev logs
The whole thing is not optimized at all and probably pretty inefficent.
Request body and headers are logged if the debug output is enabled.
```
Creating a new task:
PUT /dav/lists/1/cd4dd0e1b3c19cc9d787829b6e08be536e3df3a4.ics
Body:
BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
VERSION:2.0
BEGIN:VTODO
UID:cd4dd0e1b3c19cc9d787829b6e08be536e3df3a4
DTSTAMP:20190508T134538Z
SUMMARY:test2000
PRIORITY:0
CLASS:PUBLIC
CREATED:20190508T134710Z
LAST-MODIFIED:20190508T134710Z
END:VTODO
END:VCALENDAR
Marking a task as done:
BEGIN:VCALENDAR
CALSCALE:GREGORIAN
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
VERSION:2.0
BEGIN:VTODO
UID:3ada92f28b4ceda38562ebf047c6ff05400d4c572352a
DTSTAMP:20190511T183631
DTSTART:19700101T000000
DTEND:19700101T000000
SUMMARY:sdgs
ORGANIZER;CN=:user
CREATED:20190511T183631
PRIORITY:0
LAST-MODIFIED:20190512T193428Z
COMPLETED:20190512T193428Z
PERCENT-COMPLETE:100
STATUS:COMPLETED
END:VTODO
END:VCALENDAR
Requests from the app:::
[CALDAV] Request Body: <?xml version="1.0" encoding="UTF-8" ?><propfind xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav"><prop><current-user-principal /></prop></propfind>
[CALDAV] GetResources: rpath: /dav/
2019-05-18T23:25:49.971140654+02:00: WEB ▶ 192.168.1.134 PROPFIND 207 /dav/ 1.021705664s - okhttp/3.12.2
[CALDAV] Request Body: <?xml version="1.0" encoding="UTF-8" ?><propfind xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav"><prop><CAL:calendar-home-set /></prop></propfind>
[CALDAV] GetResources: rpath: /dav/
2019-05-18T23:25:52.166996113+02:00: WEB ▶ 192.168.1.134 PROPFIND 207 /dav/ 1.042834467s - okhttp/3.12.2
And then it just stops.
... and complains about not being able to find the home set
... without even requesting it...
```

8
go.mod
View file

@ -21,6 +21,7 @@ require (
code.vikunja.io/web v0.0.0-20190507193736-edb39812af9c
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
github.com/beevik/etree v1.1.0 // indirect
github.com/client9/misspell v0.3.4
github.com/d4l3k/messagediff v1.2.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
@ -43,6 +44,7 @@ require (
github.com/jgautheron/goconst v0.0.0-20170703170152-9740945f5dcb
github.com/labstack/echo/v4 v4.1.5
github.com/labstack/gommon v0.2.8
github.com/laurent22/ical-go v0.1.0
github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983 // indirect
github.com/mattn/go-oci8 v0.0.0-20181130072307-052f5d97b9b6 // indirect
github.com/mattn/go-runewidth v0.0.4 // indirect
@ -53,6 +55,7 @@ require (
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pelletier/go-toml v1.4.0 // indirect
github.com/prometheus/client_golang v0.9.2
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/spf13/afero v1.2.2 // indirect
github.com/spf13/cobra v0.0.3
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@ -71,3 +74,8 @@ require (
src.techknowlogick.com/xgo v0.0.0-20190507142556-a5b29ecb0ff4
src.techknowlogick.com/xormigrate v0.0.0-20190321151057-24497c23c09c
)
//replace (
// github.com/labstack/echo/v4 => ../../github.com/labstack/echo // Branch: feature/report-method, PR https://github.com/labstack/echo/pull/1332
// github.com/samedi/caldav-go => ../../github.com/samedi/caldav-go // Branch: feature/dynamic-supported-components, PR: https://github.com/samedi/caldav-go/pull/6
//)

8
go.sum
View file

@ -16,6 +16,9 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/beevik/etree v0.0.0-20171015221209-af219c0c7ea1/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
@ -118,6 +121,9 @@ github.com/labstack/echo/v4 v4.1.5 h1:RztCXCvfMljychg0G/IzW5T7hL6ADqqwREwcX279Q1
github.com/labstack/echo/v4 v4.1.5/go.mod h1:3LbYC6VkwmUnmLPZ8WFdHdQHG77e9GQbjyhWdb1QvC4=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/laurent22/ical-go v0.0.0-20170824131750-e4fec3492969/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
github.com/laurent22/ical-go v0.1.0 h1:4vZHBD3/+ne+IN+c3B+v6d9Ff8+70pzTjCWsjfDRvL0=
github.com/laurent22/ical-go v0.1.0/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
@ -175,6 +181,8 @@ github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jO
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE=
github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/samedi/caldav-go v3.0.0+incompatible h1:OrYCCUPvQTyGybtf64G232NPkWuQRl5hc2CC6rQNT/U=
github.com/samedi/caldav-go v3.0.0+incompatible/go.mod h1:zY411fqgoxSEcCL75WxwchM+8p4n/MnRUPfVusQke3Q=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE=

View file

@ -17,11 +17,16 @@
package caldav
import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/utils"
"fmt"
"strconv"
"time"
)
// DateFormat ist the caldav date format
const DateFormat = `20060102T150405`
// Event holds a single caldav event
type Event struct {
Summary string
@ -34,6 +39,29 @@ type Event struct {
EndUnix int64
}
// Todo holds a single VTODO
type Todo struct {
// Required
TimestampUnix int64
UID string
// Optional
Summary string
Description string
CompletedUnix int64
Organizer *models.User
Priority int64 // 0-9, 1 is highest
RelatedToUID string
StartUnix int64
EndUnix int64
DueDateUnix int64
Duration time.Duration
CreatedUnix int64
UpdatedUnix int64 // last-mod
}
// Alarm holds infos about an alarm from a caldav event
type Alarm struct {
TimeUnix int64
@ -92,10 +120,89 @@ END:VCALENDAR` // Need a line break
return
}
// ParseTodos returns a caldav vcalendar string with todos
func ParseTodos(config *Config, todos []*Todo) (caldavtodos string) {
caldavtodos = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:` + config.Name + `
PRODID:-//` + config.ProdID + `//EN`
for _, t := range todos {
if t.UID == "" {
t.UID = makeCalDavTimeFromUnixTime(t.TimestampUnix) + utils.Sha256(t.Summary)
}
caldavtodos += `
BEGIN:VTODO
UID:` + t.UID + `
DTSTAMP:` + makeCalDavTimeFromUnixTime(t.TimestampUnix) + `
SUMMARY:` + t.Summary
if t.StartUnix != 0 {
caldavtodos += `
DTSTART: ` + makeCalDavTimeFromUnixTime(t.StartUnix)
}
if t.EndUnix != 0 {
caldavtodos += `
DTEND: ` + makeCalDavTimeFromUnixTime(t.EndUnix)
}
if t.Description != "" {
caldavtodos += `
DESCRIPTION:` + t.Description
}
if t.CompletedUnix != 0 {
caldavtodos += `
COMPLETED: ` + makeCalDavTimeFromUnixTime(t.CompletedUnix)
}
if t.Organizer != nil {
caldavtodos += `
ORGANIZER;CN=:` + t.Organizer.Username
}
if t.RelatedToUID != "" {
caldavtodos += `
RELATED-TO:` + t.RelatedToUID
}
if t.DueDateUnix != 0 {
caldavtodos += `
DUE:` + makeCalDavTimeFromUnixTime(t.DueDateUnix)
}
if t.CreatedUnix != 0 {
caldavtodos += `
CREATED:` + makeCalDavTimeFromUnixTime(t.CreatedUnix)
}
if t.Duration != 0 {
caldavtodos += `
DURATION:PT` + fmt.Sprintf("%.6f", t.Duration.Hours()) + `H` + fmt.Sprintf("%.6f", t.Duration.Minutes()) + `M` + fmt.Sprintf("%.6f", t.Duration.Seconds()) + `S`
}
if t.Priority != 0 {
caldavtodos += `
PRIORITY:` + strconv.Itoa(int(t.Priority))
}
caldavtodos += `
LAST-MODIFIED:` + makeCalDavTimeFromUnixTime(t.UpdatedUnix)
caldavtodos += `
END:VTODO`
}
caldavtodos += `
END:VCALENDAR` // Need a line break
return
}
func makeCalDavTimeFromUnixTime(unixtime int64) (caldavtime string) {
tz, _ := time.LoadLocation("UTC")
tm := time.Unix(unixtime, 0).In(tz)
return tm.Format("20060102T150405")
return tm.Format(DateFormat)
}
func calcAlarmDateFromReminder(eventStartUnix, reminderUnix int64) (alarmTime string) {

View file

@ -40,6 +40,7 @@ func InitConfig() {
viper.SetDefault("service.JWTSecret", random)
viper.SetDefault("service.interface", ":3456")
viper.SetDefault("service.frontendurl", "")
viper.SetDefault("service.enablecaldav", true)
ex, err := os.Executable()
if err != nil {

View file

@ -80,33 +80,33 @@ func TestListTask(t *testing.T) {
t.Run("by priority", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"priority"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
})
t.Run("by priority desc", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"prioritydesc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
})
t.Run("by priority asc", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"priorityasc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
assert.Contains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
})
// should equal duedate desc
t.Run("by duedate", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"dueadate"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
})
t.Run("by duedate desc", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"dueadatedesc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
})
t.Run("by duedate asc", func(t *testing.T) {
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"duedateasc"}}, nil)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"doneAt":0,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
})
t.Run("invalid parameter", func(t *testing.T) {
// Invalid parameter should not sort at all

View file

@ -0,0 +1,84 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"code.vikunja.io/api/pkg/utils"
"github.com/go-xorm/xorm"
"src.techknowlogick.com/xormigrate"
)
type listTask20190511202210 struct {
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"`
Text string `xorm:"varchar(250) not null" json:"text" valid:"runelength(3|250)" minLength:"3" maxLength:"250"`
Description string `xorm:"varchar(250)" json:"description" valid:"runelength(0|250)" maxLength:"250"`
Done bool `xorm:"INDEX null" json:"done"`
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
DueDateUnix int64 `xorm:"int(11) INDEX null" json:"dueDate"`
RemindersUnix []int64 `xorm:"JSON TEXT null" json:"reminderDates"`
CreatedByID int64 `xorm:"int(11) not null" json:"-"` // ID of the user who put that task on the list
ListID int64 `xorm:"int(11) INDEX not null" json:"listID" param:"list"`
RepeatAfter int64 `xorm:"int(11) INDEX null" json:"repeatAfter"`
ParentTaskID int64 `xorm:"int(11) INDEX null" json:"parentTaskID"`
Priority int64 `xorm:"int(11) null" json:"priority"`
StartDateUnix int64 `xorm:"int(11) INDEX null" json:"startDate" query:"-"`
EndDateUnix int64 `xorm:"int(11) INDEX null" json:"endDate" query:"-"`
HexColor string `xorm:"varchar(6) null" json:"hexColor" valid:"runelength(0|6)" maxLength:"6"`
UID string `xorm:"varchar(250) null" json:"-"`
Sorting string `xorm:"-" json:"-" query:"sort"` // Parameter to sort by
StartDateSortUnix int64 `xorm:"-" json:"-" query:"startdate"`
EndDateSortUnix int64 `xorm:"-" json:"-" query:"enddate"`
Created int64 `xorm:"created not null" json:"created"`
Updated int64 `xorm:"updated not null" json:"updated"`
}
func (listTask20190511202210) TableName() string {
return "tasks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20190511202210",
Description: "Add task uid",
Migrate: func(tx *xorm.Engine) error {
err := tx.Sync2(listTask20190511202210{})
if err != nil {
return err
}
// Get all tasks and generate a random uid for them
var allTasks []*listTask20190511202210
err = tx.Find(&allTasks)
if err != nil {
return err
}
for _, t := range allTasks {
t.UID = utils.MakeRandomString(40)
_, err = tx.Where("id = ?", t.ID).Cols("uid").Update(t)
if err != nil {
return err
}
}
return nil
},
Rollback: func(tx *xorm.Engine) error {
return dropTableColum(tx, "tasks", "uid")
},
})
}

View file

@ -0,0 +1,43 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"github.com/go-xorm/xorm"
"src.techknowlogick.com/xormigrate"
)
type listTask20190514192749 struct {
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
}
func (listTask20190514192749) TableName() string {
return "tasks"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20190514192749",
Description: "Add task done at",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(listTask20190514192749{})
},
Rollback: func(tx *xorm.Engine) error {
return dropTableColum(tx, "tasks", "done_at_unix")
},
})
}

View file

@ -86,6 +86,11 @@ func (lt *LabelTask) Create(a web.Auth) (err error) {
// Insert it
_, err = x.Insert(lt)
if err != nil {
return err
}
err = updateListByTaskID(lt.TaskID)
return
}
@ -272,6 +277,8 @@ func (t *ListTask) updateTaskLabels(creator web.Auth, labels []*Label) (err erro
}
t.Labels = append(t.Labels, label)
}
err = updateListLastUpdated(&List{ID: t.ListID})
return
}
@ -299,7 +306,7 @@ type LabelTaskBulk struct {
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/labels/bulk [post]
func (ltb *LabelTaskBulk) Create(a web.Auth) (err error) {
task, err := GetListTaskByID(ltb.TaskID)
task, err := GetTaskByID(ltb.TaskID)
if err != nil {
return
}

View file

@ -69,7 +69,7 @@ func (ltb *LabelTaskBulk) CanCreate(a web.Auth) (bool, error) {
// always the same check for either deleting or adding a label to a task
func canDoLabelTask(taskID int64, a web.Auth) (bool, error) {
// A user can add a label to a task if he can write to the task
task, err := getTaskByIDSimple(taskID)
task, err := GetTaskByIDSimple(taskID)
if err != nil {
return false, err
}

View file

@ -166,6 +166,8 @@ func TestLabelTask_Create(t *testing.T) {
a: &User{ID: 1},
},
wantForbidden: true,
wantErr: true,
errType: IsErrListTaskDoesNotExist,
},
}
for _, tt := range tests {

View file

@ -71,6 +71,21 @@ func (l *List) Update() (err error) {
return CreateOrUpdateList(l)
}
func updateListLastUpdated(list *List) error {
_, err := x.ID(list.ID).Cols("updated").Update(list)
return err
}
func updateListByTaskID(taskID int64) (err error) {
// need to get the task to update the list last updated timestamp
task, err := GetTaskByIDSimple(taskID)
if err != nil {
return err
}
return updateListLastUpdated(&List{ID: task.ListID})
}
// Create implements the create method of CRUDable
// @Summary Creates a new list
// @Description Creates a new list in a given namespace. The user needs write-access to the namespace.

View file

@ -125,6 +125,8 @@ func (t *ListTask) updateTaskAssignees(assignees []*User) (err error) {
}
t.setTaskAssignees(assignees)
err = updateListLastUpdated(&List{ID: t.ListID})
return
}
@ -152,6 +154,11 @@ func (t *ListTask) setTaskAssignees(assignees []*User) {
// @Router /tasks/{taskID}/assignees/{userID} [delete]
func (la *ListTaskAssginee) Delete() (err error) {
_, err = x.Delete(&ListTaskAssginee{TaskID: la.TaskID, UserID: la.UserID})
if err != nil {
return err
}
err = updateListByTaskID(la.TaskID)
return
}
@ -198,7 +205,11 @@ func (t *ListTask) addNewAssigneeByID(newAssigneeID int64, list *List) (err erro
TaskID: t.ID,
UserID: newAssigneeID,
})
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: t.ListID})
return
}
@ -249,7 +260,7 @@ type BulkAssignees struct {
// @Failure 500 {object} models.Message "Internal error"
// @Router /tasks/{taskID}/assignees/bulk [post]
func (ba *BulkAssignees) Create(a web.Auth) (err error) {
task, err := GetListTaskByID(ba.TaskID) // We need to use the full method here because we need all current assignees.
task, err := GetTaskByID(ba.TaskID) // We need to use the full method here because we need all current assignees.
if err != nil {
return
}

View file

@ -31,6 +31,8 @@ type ListTask struct {
Description string `xorm:"varchar(250)" json:"description" valid:"runelength(0|250)" maxLength:"250"`
// Whether a task is done or not.
Done bool `xorm:"INDEX null" json:"done"`
// The unix timestamp when a task was marked as done.
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
// A unix timestamp when the task is due.
DueDateUnix int64 `xorm:"int(11) INDEX null" json:"dueDate"`
// An array of unix timestamps when the user wants to be reminded of the task.
@ -55,6 +57,9 @@ type ListTask struct {
// The task color in hex
HexColor string `xorm:"varchar(6) null" json:"hexColor" valid:"runelength(0|6)" maxLength:"6"`
// The UID is currently not used for anything other than caldav, which is why we don't expose it over json
UID string `xorm:"varchar(250) null" json:"-"`
Sorting string `xorm:"-" json:"-" query:"sort"` // Parameter to sort by
StartDateSortUnix int64 `xorm:"-" json:"-" query:"startdate"`
EndDateSortUnix int64 `xorm:"-" json:"-" query:"enddate"`
@ -88,96 +93,36 @@ func GetTasksByListID(listID int64) (tasks []*ListTask, err error) {
return
}
// No need to iterate over users and stuff if the list doesn't has tasks
if len(taskMap) == 0 {
return
}
// Get all users & task ids and put them into the array
var userIDs []int64
var taskIDs []int64
for _, i := range taskMap {
taskIDs = append(taskIDs, i.ID)
userIDs = append(userIDs, i.CreatedByID)
}
// Get all assignees
taskAssignees, err := getRawTaskAssigneesForTasks(taskIDs)
if err != nil {
return
}
// Put the assignees in the task map
for _, a := range taskAssignees {
if a != nil {
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &a.User)
}
}
// Get all labels for the tasks
labels, err := getLabelsByTaskIDs(&LabelByTaskIDsOptions{TaskIDs: taskIDs})
if err != nil {
return
}
for _, l := range labels {
if l != nil {
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &l.Label)
}
}
users := make(map[int64]*User)
err = x.In("id", userIDs).Find(&users)
if err != nil {
return
}
// Add all user objects to the appropriate tasks
for _, task := range taskMap {
// Make created by user objects
taskMap[task.ID].CreatedBy = *users[task.CreatedByID]
// Reorder all subtasks
if task.ParentTaskID != 0 {
taskMap[task.ParentTaskID].Subtasks = append(taskMap[task.ParentTaskID].Subtasks, task)
delete(taskMap, task.ID)
}
}
// make a complete slice from the map
tasks = []*ListTask{}
for _, t := range taskMap {
tasks = append(tasks, t)
}
// Sort the output. In Go, contents on a map are put on that map in no particular order (saved on heap).
// Because of this, tasks are not sorted anymore in the output, this leads to confiusion.
// To avoid all this, we need to sort the slice afterwards
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].ID < tasks[j].ID
})
tasks, err = addMoreInfoToTasks(taskMap)
return
}
func getTaskByIDSimple(taskID int64) (task ListTask, err error) {
// GetTaskByIDSimple returns a raw task without extra data by the task ID
func GetTaskByIDSimple(taskID int64) (task ListTask, err error) {
if taskID < 1 {
return ListTask{}, ErrListTaskDoesNotExist{taskID}
}
exists, err := x.ID(taskID).Get(&task)
return GetTaskSimple(&ListTask{ID: taskID})
}
// GetTaskSimple returns a raw task without extra data
func GetTaskSimple(t *ListTask) (task ListTask, err error) {
task = *t
exists, err := x.Get(&task)
if err != nil {
return ListTask{}, err
}
if !exists {
return ListTask{}, ErrListTaskDoesNotExist{taskID}
return ListTask{}, ErrListTaskDoesNotExist{t.ID}
}
return
}
// GetListTaskByID returns all tasks a list has
func GetListTaskByID(listTaskID int64) (listTask ListTask, err error) {
listTask, err = getTaskByIDSimple(listTaskID)
// GetTaskByID returns all tasks a list has
func GetTaskByID(listTaskID int64) (listTask ListTask, err error) {
listTask, err = GetTaskByIDSimple(listTaskID)
if err != nil {
return
}
@ -221,37 +166,101 @@ func (bt *BulkTask) GetTasksByIDs() (err error) {
}
}
err = x.In("id", bt.IDs).Find(&bt.Tasks)
taskMap := make(map[int64]*ListTask, len(bt.Tasks))
err = x.In("id", bt.IDs).Find(&taskMap)
if err != nil {
return err
return
}
// We use a map, to avoid looping over two slices at once
var usermapids = make(map[int64]bool) // Bool ist just something, doesn't acutually matter
for _, list := range bt.Tasks {
usermapids[list.CreatedByID] = true
}
bt.Tasks, err = addMoreInfoToTasks(taskMap)
return
}
// Make a slice from the map
var userids []int64
for uid := range usermapids {
userids = append(userids, uid)
}
// Get all users for the tasks
var users []*User
err = x.In("id", userids).Find(&users)
// GetTasksByUIDs gets all tasks from a bunch of uids
func GetTasksByUIDs(uids []string) (tasks []*ListTask, err error) {
taskMap := make(map[int64]*ListTask)
err = x.In("uid", uids).Find(&taskMap)
if err != nil {
return err
return
}
for in, task := range bt.Tasks {
for _, u := range users {
if task.CreatedByID == u.ID {
bt.Tasks[in].CreatedBy = *u
}
tasks, err = addMoreInfoToTasks(taskMap)
return
}
// This function takes a map with pointers and returns a slice with pointers to tasks
// It adds more stuff like assignees/labels/etc to a bunch of tasks
func addMoreInfoToTasks(taskMap map[int64]*ListTask) (tasks []*ListTask, err error) {
// No need to iterate over users and stuff if the list doesn't has tasks
if len(taskMap) == 0 {
return
}
// Get all users & task ids and put them into the array
var userIDs []int64
var taskIDs []int64
for _, i := range taskMap {
taskIDs = append(taskIDs, i.ID)
userIDs = append(userIDs, i.CreatedByID)
}
// Get all assignees
taskAssignees, err := getRawTaskAssigneesForTasks(taskIDs)
if err != nil {
return
}
// Put the assignees in the task map
for _, a := range taskAssignees {
if a != nil {
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &a.User)
}
}
// Get all labels for all the tasks
labels, err := getLabelsByTaskIDs(&LabelByTaskIDsOptions{TaskIDs: taskIDs})
if err != nil {
return
}
for _, l := range labels {
if l != nil {
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &l.Label)
}
}
// Get all users of a task
// aka the ones who created a task
users := make(map[int64]*User)
err = x.In("id", userIDs).Find(&users)
if err != nil {
return
}
// Add all user objects to the appropriate tasks
for _, task := range taskMap {
// Make created by user objects
taskMap[task.ID].CreatedBy = *users[task.CreatedByID]
// Reorder all subtasks
if task.ParentTaskID != 0 {
taskMap[task.ParentTaskID].Subtasks = append(taskMap[task.ParentTaskID].Subtasks, task)
delete(taskMap, task.ID)
}
}
// make a complete slice from the map
tasks = []*ListTask{}
for _, t := range taskMap {
tasks = append(tasks, t)
}
// Sort the output. In Go, contents on a map are put on that map in no particular order.
// Because of this, tasks are not sorted anymore in the output, this leads to confiusion.
// To avoid all this, we need to sort the slice afterwards
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].ID < tasks[j].ID
})
return
}

View file

@ -18,8 +18,10 @@ package models
import (
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/web"
"github.com/imdario/mergo"
"time"
)
// Create is the implementation to create a list task
@ -60,6 +62,11 @@ func (t *ListTask) Create(a web.Auth) (err error) {
return err
}
// Generate a uuid if we don't already have one
if t.UID == "" {
t.UID = utils.MakeRandomString(40)
}
t.CreatedByID = u.ID
t.CreatedBy = u
if _, err = x.Insert(t); err != nil {
@ -72,6 +79,8 @@ func (t *ListTask) Create(a web.Auth) (err error) {
}
metrics.UpdateCount(1, metrics.TaskCountKey)
err = updateListLastUpdated(&List{ID: t.ListID})
return
}
@ -91,7 +100,7 @@ func (t *ListTask) Create(a web.Auth) (err error) {
// @Router /tasks/{id} [post]
func (t *ListTask) Update() (err error) {
// Check if the task exists
ot, err := GetListTaskByID(t.ID)
ot, err := GetTaskByID(t.ID)
if err != nil {
return
}
@ -189,12 +198,20 @@ func (t *ListTask) Update() (err error) {
"priority",
"start_date_unix",
"end_date_unix",
"hex_color").
"hex_color",
"done_at_unix").
Update(ot)
*t = ot
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: t.ListID})
return
}
// This helper function updates the reminders and doneAtUnix of the *old* task (since that's the one we're inserting
// with updated values into the db)
func updateDone(oldTask *ListTask, newTask *ListTask) {
if !oldTask.Done && newTask.Done && oldTask.RepeatAfter > 0 {
oldTask.DueDateUnix = oldTask.DueDateUnix + oldTask.RepeatAfter // assuming we'll save the old task (merged)
@ -205,4 +222,13 @@ func updateDone(oldTask *ListTask, newTask *ListTask) {
newTask.Done = false
}
// Update the "done at" timestamp
if !oldTask.Done && newTask.Done {
oldTask.DoneAtUnix = time.Now().Unix()
}
// When unmarking a task as done, reset the timestamp
if oldTask.Done && !newTask.Done {
oldTask.DoneAtUnix = 0
}
}

View file

@ -36,7 +36,7 @@ import (
func (t *ListTask) Delete() (err error) {
// Check if it exists
_, err = GetListTaskByID(t.ID)
_, err = GetTaskByID(t.ID)
if err != nil {
return
}
@ -51,5 +51,7 @@ func (t *ListTask) Delete() (err error) {
}
metrics.UpdateCount(-1, metrics.TaskCountKey)
err = updateListLastUpdated(&List{ID: t.ListID})
return
}

View file

@ -43,7 +43,7 @@ func (t *ListTask) CanCreate(a web.Auth) (bool, error) {
func (t *ListTask) CanRead(a web.Auth) (canRead bool, err error) {
//return t.canDoListTask(a)
// Get the task, error out if it doesn't exist
*t, err = getTaskByIDSimple(t.ID)
*t, err = GetTaskByIDSimple(t.ID)
if err != nil {
return
}
@ -58,7 +58,7 @@ func (t *ListTask) canDoListTask(a web.Auth) (bool, error) {
doer := getUserForRights(a)
// Get the task
lI, err := getTaskByIDSimple(t.ID)
lI, err := GetTaskByIDSimple(t.ID)
if err != nil {
return false, err
}

View file

@ -49,7 +49,7 @@ func TestListTask_Create(t *testing.T) {
assert.NoError(t, err)
// Check if it was updated
li, err := GetListTaskByID(listtask.ID)
li, err := GetTaskByID(listtask.ID)
assert.NoError(t, err)
assert.Equal(t, li.Text, "Test34")
@ -91,3 +91,18 @@ func TestListTask_Create(t *testing.T) {
assert.Error(t, err)
assert.True(t, IsErrUserDoesNotExist(err))
}
func TestUpdateDone(t *testing.T) {
t.Run("marking a task as done", func(t *testing.T) {
oldTask := &ListTask{Done: false}
newTask := &ListTask{Done: true}
updateDone(oldTask, newTask)
assert.NotEqual(t, int64(0), oldTask.DoneAtUnix)
})
t.Run("unmarking a task as done", func(t *testing.T) {
oldTask := &ListTask{Done: true}
newTask := &ListTask{Done: false}
updateDone(oldTask, newTask)
assert.Equal(t, int64(0), oldTask.DoneAtUnix)
})
}

View file

@ -67,6 +67,10 @@ func (lu *ListUser) Create(a web.Auth) (err error) {
// Insert user <-> list relation
_, err = x.Insert(lu)
if err != nil {
return err
}
err = updateListLastUpdated(l)
return
}

View file

@ -51,5 +51,10 @@ func (lu *ListUser) Delete() (err error) {
_, err = x.Where("user_id = ? AND list_id = ?", lu.UserID, lu.ListID).
Delete(&ListUser{})
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: lu.ListID})
return
}

View file

@ -44,5 +44,10 @@ func (lu *ListUser) Update() (err error) {
Where("list_id = ? AND user_id = ?", lu.ListID, lu.UserID).
Cols("right").
Update(lu)
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: lu.ListID})
return
}

View file

@ -65,5 +65,10 @@ func (tl *TeamList) Create(a web.Auth) (err error) {
// Insert the new team
_, err = x.Insert(tl)
if err != nil {
return err
}
err = updateListLastUpdated(l)
return
}

View file

@ -53,6 +53,10 @@ func (tl *TeamList) Delete() (err error) {
_, err = x.Where("team_id = ?", tl.TeamID).
And("list_id = ?", tl.ListID).
Delete(TeamList{})
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: tl.ListID})
return
}

View file

@ -44,5 +44,10 @@ func (tl *TeamList) Update() (err error) {
Where("list_id = ? AND team_id = ?", tl.ListID, tl.TeamID).
Cols("right").
Update(tl)
if err != nil {
return err
}
err = updateListLastUpdated(&List{ID: tl.ListID})
return
}

View file

@ -115,7 +115,7 @@ func CreateUser(user User) (newUser User, err error) {
// HashPassword hashes a password
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11)
return string(bytes), err
}

View file

@ -1,89 +0,0 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2018 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package v1
import (
"code.vikunja.io/api/pkg/caldav"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/web/handler"
"github.com/labstack/echo/v4"
"net/http"
"time"
)
// Caldav returns a caldav-readable format with all tasks
// @Summary CalDAV-readable format with all tasks as calendar events.
// @Description Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.
// @tags task
// @Produce text/plain
// @Security BasicAuth
// @Success 200 {string} string "The caldav events."
// @Failure 403 {string} string "Unauthorized."
// @Router /tasks/caldav [get]
func Caldav(c echo.Context) error {
// Request basic auth
user, pass, ok := c.Request().BasicAuth()
// Check credentials
creds := &models.UserLogin{
Username: user,
Password: pass,
}
u, err := models.CheckUserCredentials(creds)
if !ok || err != nil {
c.Response().Header().Set("WWW-Authenticate", `Basic realm="Vikunja cal"`)
return c.String(http.StatusUnauthorized, "Unauthorized.")
}
// Get all tasks for that user
tasks, err := models.GetTasksByUser("", &u, -1, models.SortTasksByUnsorted, time.Now(), time.Now().Add(24*356*time.Hour))
if err != nil {
return handler.HandleHTTPError(err, c)
}
hour := int64(time.Hour.Seconds())
var caldavTasks []*caldav.Event
for _, t := range tasks {
if t.DueDateUnix != 0 {
event := &caldav.Event{
Summary: t.Text,
Description: t.Description,
UID: "",
TimestampUnix: t.Updated,
StartUnix: t.DueDateUnix,
EndUnix: t.DueDateUnix + hour,
}
if len(t.RemindersUnix) > 0 {
for _, rem := range t.RemindersUnix {
event.Alarms = append(event.Alarms, caldav.Alarm{TimeUnix: rem})
}
}
caldavTasks = append(caldavTasks, event)
}
}
caldavConfig := &caldav.Config{
Name: "Vikunja Calendar for " + u.Username,
ProdID: "Vikunja Todo App",
}
return c.String(http.StatusOK, caldav.ParseEvents(caldavConfig, caldavTasks))
}

View file

@ -0,0 +1,184 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
import (
"bytes"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/web/handler"
"fmt"
"github.com/labstack/echo/v4"
"github.com/samedi/caldav-go"
"github.com/samedi/caldav-go/lib"
"io/ioutil"
"reflect"
"strconv"
"strings"
)
func getBasicAuthUserFromContext(c echo.Context) (user models.User, err error) {
u, is := c.Get("userBasicAuth").(models.User)
if !is {
return models.User{}, fmt.Errorf("user is not user element, is %s", reflect.TypeOf(c.Get("userBasicAuth")))
}
return u, nil
}
// ListHandler returns all tasks from a list
func ListHandler(c echo.Context) error {
listID, err := getIntParam(c, "list")
if err != nil {
return err
}
u, err := getBasicAuthUserFromContext(c)
if err != nil {
log.Log.Error(err)
return echo.ErrInternalServerError
}
storage := &VikunjaCaldavListStorage{
list: &models.List{ID: listID},
user: &u,
}
// Try to parse a task from the request payload
body, _ := ioutil.ReadAll(c.Request().Body)
// Restore the io.ReadCloser to its original state
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
// Parse it
vtodo := string(body)
if vtodo != "" && strings.HasPrefix(vtodo, `BEGIN:VCALENDAR`) {
storage.task, err = parseTaskFromVTODO(vtodo)
if err != nil {
log.Log.Error(err)
return echo.ErrInternalServerError
}
}
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
caldav.SetupStorage(storage)
caldav.SetupUser("dav/lists")
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
response := caldav.HandleRequest(c.Request())
response.Write(c.Response())
return nil
}
// TaskHandler is the handler which manages updating/deleting a single task
func TaskHandler(c echo.Context) error {
listID, err := getIntParam(c, "list")
if err != nil {
return err
}
u, err := getBasicAuthUserFromContext(c)
if err != nil {
log.Log.Error(err)
return echo.ErrInternalServerError
}
// Get the task uid
taskUID := strings.TrimSuffix(c.Param("task"), ".ics")
storage := &VikunjaCaldavListStorage{
list: &models.List{ID: listID},
task: &models.ListTask{UID: taskUID},
user: &u,
}
caldav.SetupStorage(storage)
response := caldav.HandleRequest(c.Request())
response.Write(c.Response())
return nil
}
// PrincipalHandler handles all request to principal resources
func PrincipalHandler(c echo.Context) error {
u, err := getBasicAuthUserFromContext(c)
if err != nil {
log.Log.Error(err)
return echo.ErrInternalServerError
}
storage := &VikunjaCaldavListStorage{
user: &u,
isPrincipal: true,
}
// Try to parse a task from the request payload
body, _ := ioutil.ReadAll(c.Request().Body)
// Restore the io.ReadCloser to its original state
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
caldav.SetupStorage(storage)
caldav.SetupUser("dav/principals/" + u.Username)
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
response := caldav.HandleRequest(c.Request())
response.Write(c.Response())
return nil
}
// EntryHandler handles all request to principal resources
func EntryHandler(c echo.Context) error {
u, err := getBasicAuthUserFromContext(c)
if err != nil {
log.Log.Error(err)
return echo.ErrInternalServerError
}
storage := &VikunjaCaldavListStorage{
user: &u,
isEntry: true,
}
// Try to parse a task from the request payload
body, _ := ioutil.ReadAll(c.Request().Body)
// Restore the io.ReadCloser to its original state
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
caldav.SetupStorage(storage)
caldav.SetupUser("dav/principals/" + u.Username)
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
response := caldav.HandleRequest(c.Request())
response.Write(c.Response())
return nil
}
func getIntParam(c echo.Context, paramName string) (intParam int64, err error) {
param := c.Param(paramName)
if param == "" {
return 0, nil
}
intParam, err = strconv.ParseInt(param, 10, 64)
if err != nil {
return 0, handler.HandleHTTPError(err, c)
}
return
}

View file

@ -0,0 +1,404 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
import (
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/errs"
"strconv"
"strings"
"time"
)
// DavBasePath is the base url path
const DavBasePath = `/dav/`
// ListBasePath is the base path for all lists resources
const ListBasePath = DavBasePath + `lists`
// VikunjaCaldavListStorage represents a list storage
type VikunjaCaldavListStorage struct {
// Used when handling a list
list *models.List
// Used when handling a single task, like updating
task *models.ListTask
// The current user
user *models.User
isPrincipal bool
isEntry bool // Entry level handling should only return a link to the principal url
}
// GetResources returns either all lists, links to the principal, or only one list, depending on the request
func (vcls *VikunjaCaldavListStorage) GetResources(rpath string, withChildren 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/lists, the calendar home is /dav/lists", but the client expects a
// response to go something like "hey, for /dav/, the calendar home is /dav/lists" since it requested /dav/
// and not /dav/lists. I'm not sure if thats a bug in the client or in caldav-go.
if vcls.isEntry {
r := data.NewResource(rpath, &VikunjaListResourceAdapter{
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+`/lists/`, &VikunjaListResourceAdapter{
isPrincipal: true,
isCollection: true,
})
return []data.Resource{r}, nil
}
// If vcls.list.ID is != 0, this means the user is doing a PROPFIND request to /lists/:list
// Which means we need to get only one list
if vcls.list != nil && vcls.list.ID != 0 {
rr, err := vcls.getListRessource(true)
if err != nil {
return nil, err
}
r := data.NewResource(rpath, &rr)
r.Name = vcls.list.Title
return []data.Resource{r}, nil
}
// Otherwise get all lists
thelists, err := vcls.list.ReadAll("", vcls.user, -1)
if err != nil {
return nil, err
}
lists := thelists.([]*models.List)
var resources []data.Resource
for _, l := range lists {
rr := VikunjaListResourceAdapter{
list: l,
isCollection: true,
}
r := data.NewResource(ListBasePath+"/"+strconv.FormatInt(l.ID, 10), &rr)
r.Name = l.Title
resources = append(resources, r)
}
return resources, nil
}
// GetResourcesByList fetches a list of resources from a slice of paths
func (vcls *VikunjaCaldavListStorage) GetResourcesByList(rpaths []string) ([]data.Resource, error) {
// Parse the set of resourcepaths into usable uids
// A path looks like this: /dav/lists/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, "/")
uid := []rune(parts[4]) // The 4th part is the id with ".ics" suffix
endlen := len(uid) - 4 // ".ics" are 4 bytes
uids = append(uids, string(uid[:endlen]))
}
// GetTasksByUIDs...
// Parse these into ressources...
tasks, err := models.GetTasksByUIDs(uids)
if err != nil {
return nil, err
}
var resources []data.Resource
for _, t := range tasks {
rr := VikunjaListResourceAdapter{
task: t,
}
r := data.NewResource(getTaskURL(t), &rr)
r.Name = t.Text
resources = append(resources, r)
}
return resources, nil
}
// GetResourcesByFilters fetches a list of resources with a filter
func (vcls *VikunjaCaldavListStorage) GetResourcesByFilters(rpath string, filters *data.ResourceFilter) ([]data.Resource, error) {
// If we already have a list 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 list is coming from a previous "getListRessource" in L177
if vcls.list.Tasks != nil {
var resources []data.Resource
for _, t := range vcls.list.Tasks {
rr := VikunjaListResourceAdapter{
list: vcls.list,
task: t,
isCollection: false,
}
r := data.NewResource(getTaskURL(t), &rr)
r.Name = t.Text
resources = append(resources, r)
}
return resources, nil
}
// This is used to get all
rr, err := vcls.getListRessource(false)
if err != nil {
return nil, err
}
r := data.NewResource(rpath, &rr)
r.Name = vcls.list.Title
return []data.Resource{r}, nil
// For now, filtering is disabled.
//return vcls.GetResources(rpath, false)
}
func getTaskURL(task *models.ListTask) string {
return ListBasePath + "/" + strconv.FormatInt(task.ListID, 10) + `/` + task.UID + `.ics`
}
// GetResource fetches a single resource
func (vcls *VikunjaCaldavListStorage) GetResource(rpath string) (*data.Resource, bool, error) {
// If the task is not nil, we need to get the task and not the list
if vcls.task != nil {
// save and override the updated unix date to not break any later etag checks
updated := vcls.task.Updated
task, err := models.GetTaskSimple(&models.ListTask{ID: vcls.task.ID, UID: vcls.task.UID})
if err != nil {
if models.IsErrListTaskDoesNotExist(err) {
return nil, false, errs.ResourceNotFoundError
}
return nil, false, err
}
vcls.task = &task
if updated > 0 {
vcls.task.Updated = updated
}
rr := VikunjaListResourceAdapter{
list: vcls.list,
task: &task,
}
r := data.NewResource(rpath, &rr)
return &r, true, nil
}
// Otherwise get the list with all tasks
rr, err := vcls.getListRessource(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 *VikunjaCaldavListStorage) 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 list with no tasks whatsoever, nothing else
return vcls.GetResource(rpath)
}
// CreateResource creates a new resource
func (vcls *VikunjaCaldavListStorage) CreateResource(rpath, content string) (*data.Resource, error) {
vTask, err := parseTaskFromVTODO(content)
if err != nil {
return nil, err
}
vTask.ListID = vcls.list.ID
// Check the rights
canCreate, err := vTask.CanCreate(vcls.user)
if err != nil {
return nil, err
}
if !canCreate {
return nil, errs.ForbiddenError
}
// Create the task
err = vTask.Create(vcls.user)
if err != nil {
return nil, err
}
// Build up the proper response
rr := VikunjaListResourceAdapter{
list: vcls.list,
task: vTask,
}
r := data.NewResource(rpath, &rr)
return &r, nil
}
// UpdateResource updates a resource
func (vcls *VikunjaCaldavListStorage) UpdateResource(rpath, content string) (*data.Resource, error) {
vTask, err := 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
// Check the rights
canUpdate, err := vTask.CanUpdate(vcls.user)
if err != nil {
return nil, err
}
if !canUpdate {
return nil, errs.ForbiddenError
}
// Update the task
err = vTask.Update()
if err != nil {
return nil, err
}
// Build up the proper response
rr := VikunjaListResourceAdapter{
list: vcls.list,
task: vTask,
}
r := data.NewResource(rpath, &rr)
return &r, nil
}
// DeleteResource deletes a resource
func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error {
if vcls.task != nil {
// Check the rights
canDelete, err := vcls.task.CanDelete(vcls.user)
if err != nil {
return err
}
if !canDelete {
return errs.ForbiddenError
}
// Delete it
return vcls.task.Delete()
}
return nil
}
// VikunjaListResourceAdapter holds the actual resource
type VikunjaListResourceAdapter struct {
list *models.List
task *models.ListTask
isPrincipal bool
isCollection bool
}
// IsCollection checks if the resoure in the adapter is a collection
func (vlra *VikunjaListResourceAdapter) 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 *VikunjaListResourceAdapter) CalculateEtag() string {
// If we're updating a task, the client sends the etag of the list 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 list instead.
//if vlra.list != nil {
// return `"` + strconv.FormatInt(vlra.list.ID, 10) + `-` + strconv.FormatInt(vlra.list.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, 10) + `"`
}
// This also returns the etag of the list, and not of the task,
// which becomes problematic because the client uses this etag (= the one from the list) to make
// Requests to update a task. These do not match and thus updating a task fails.
return `"` + strconv.FormatInt(vlra.list.ID, 10) + `-` + strconv.FormatInt(vlra.list.Updated, 10) + `"`
}
// GetContent returns the content string of a resource (a task in our case)
func (vlra *VikunjaListResourceAdapter) GetContent() string {
if vlra.list != nil && vlra.list.Tasks != nil {
return getCaldavTodosForTasks(vlra.list)
}
if vlra.task != nil {
list := models.List{Tasks: []*models.ListTask{vlra.task}}
return getCaldavTodosForTasks(&list)
}
return ""
}
// GetContentSize is the size of a caldav content
func (vlra *VikunjaListResourceAdapter) GetContentSize() int64 {
return int64(len(vlra.GetContent()))
}
// GetModTime returns when the resource was last modified
func (vlra *VikunjaListResourceAdapter) GetModTime() time.Time {
if vlra.task != nil {
return time.Unix(vlra.task.Updated, 0)
}
if vlra.list != nil {
return time.Unix(vlra.list.Updated, 0)
}
return time.Time{}
}
func (vcls *VikunjaCaldavListStorage) getListRessource(isCollection bool) (rr VikunjaListResourceAdapter, err error) {
can, err := vcls.list.CanRead(vcls.user)
if err != nil {
return
}
if !can {
log.Log.Errorf("User %v tried to access a caldav resource (List %v) which they are not allowed to access", vcls.user.Username, vcls.list.ID)
return rr, models.ErrUserDoesNotHaveAccessToList{ListID: vcls.list.ID}
}
err = vcls.list.ReadOne()
if err != nil {
return
}
rr = VikunjaListResourceAdapter{
list: vcls.list,
isCollection: isCollection,
}
return
}

View file

@ -0,0 +1,125 @@
// Vikunja is a todo-list application to facilitate your life.
// Copyright 2019 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 General Public License 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
import (
"code.vikunja.io/api/pkg/caldav"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"github.com/laurent22/ical-go/ical"
"strconv"
"time"
)
func getCaldavTodosForTasks(list *models.List) string {
// Make caldav todos from Vikunja todos
var caldavtodos []*caldav.Todo
for _, t := range list.Tasks {
durationString := t.EndDateUnix - t.StartDateUnix
duration, _ := time.ParseDuration(strconv.FormatInt(durationString, 10) + `s`)
caldavtodos = append(caldavtodos, &caldav.Todo{
TimestampUnix: t.Updated,
UID: t.UID,
Summary: t.Text,
Description: t.Description,
CompletedUnix: t.DoneAtUnix,
// Organizer: &t.CreatedBy, // Disabled until we figure out how this works
Priority: t.Priority,
StartUnix: t.StartDateUnix,
EndUnix: t.EndDateUnix,
CreatedUnix: t.Created,
UpdatedUnix: t.Updated,
DueDateUnix: t.DueDateUnix,
Duration: duration,
})
}
caldavConfig := &caldav.Config{
Name: list.Title,
ProdID: "Vikunja Todo App",
}
return caldav.ParseTodos(caldavConfig, caldavtodos)
}
func parseTaskFromVTODO(content string) (vTask *models.ListTask, err error) {
parsed, err := ical.ParseCalendar(content)
if err != nil {
return nil, err
}
// We put the task details in a map to be able to handle them more easily
task := make(map[string]string)
for _, c := range parsed.Children {
if c.Name == "VTODO" {
for _, entry := range c.Children {
task[entry.Name] = entry.Value
}
// Breaking, to only process the first task
break
}
}
// Parse the UID
var priority int64
if _, ok := task["PRIORITY"]; ok {
priority, err = strconv.ParseInt(task["PRIORITY"], 10, 64)
if err != nil {
return nil, err
}
}
// Parse the enddate
duration, _ := time.ParseDuration(task["DURATION"])
vTask = &models.ListTask{
UID: task["UID"],
Text: task["SUMMARY"],
Description: task["DESCRIPTION"],
Priority: priority,
DueDateUnix: caldavTimeToUnixTimestamp(task["DUE"]),
Updated: caldavTimeToUnixTimestamp(task["DTSTAMP"]),
StartDateUnix: caldavTimeToUnixTimestamp(task["DTSTART"]),
DoneAtUnix: caldavTimeToUnixTimestamp(task["COMPLETED"]),
}
if task["STATUS"] == "COMPLETED" {
vTask.Done = true
}
if duration > 0 && vTask.StartDateUnix > 0 {
vTask.EndDateUnix = vTask.StartDateUnix + int64(duration.Seconds())
}
return
}
func caldavTimeToUnixTimestamp(tstring string) int64 {
if tstring == "" {
return 0
}
t, err := time.Parse(caldav.DateFormat, tstring)
if err != nil {
log.Log.Warningf("Error while parsing caldav time %s to unix time: %s", tstring, err)
return 0
}
return t.Unix()
}

View file

@ -43,6 +43,7 @@ import (
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/models"
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
"code.vikunja.io/api/pkg/routes/caldav"
_ "code.vikunja.io/api/pkg/swagger" // To generate swagger docs
"code.vikunja.io/web"
"code.vikunja.io/web/handler"
@ -52,6 +53,7 @@ import (
elog "github.com/labstack/gommon/log"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper"
"strings"
)
// CustomValidator is a dummy struct to use govalidator with echo
@ -119,13 +121,34 @@ func NewEcho() *echo.Echo {
// RegisterRoutes registers all routes for the application
func RegisterRoutes(e *echo.Echo) {
if viper.GetBool("service.enablecaldav") {
// Caldav routes
wkg := e.Group("/.well-known")
wkg.Use(middleware.BasicAuth(caldavBasicAuth))
wkg.Any("/caldav", caldav.PrincipalHandler)
wkg.Any("/caldav/", caldav.PrincipalHandler)
c := e.Group("/dav")
registerCalDavRoutes(c)
}
// CORS_SHIT
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
Skipper: func(context echo.Context) bool {
// Since it is not possible to register this middleware just for the api group,
// we just disable it when for caldav requests.
// Caldav requires OPTIONS requests to be answered in a specific manner,
// not doing this would break the caldav implementation
return strings.HasPrefix(context.Path(), "/dav")
},
}))
// API Routes
a := e.Group("/api/v1")
registerAPIRoutes(a)
}
func registerAPIRoutes(a *echo.Group) {
// Docs
a.GET("/docs.json", apiv1.DocsJSON)
@ -192,9 +215,6 @@ func RegisterRoutes(e *echo.Echo) {
a.POST("/user/password/reset", apiv1.UserResetPassword)
a.POST("/user/confirm", apiv1.UserConfirmEmail)
// Caldav, with auth
a.GET("/tasks/caldav", apiv1.Caldav)
// ===== Routes with Authetification =====
// Authetification
a.Use(middleware.JWT([]byte(viper.GetString("service.JWTSecret"))))
@ -363,3 +383,34 @@ func RegisterRoutes(e *echo.Echo) {
a.PUT("/teams/:team/members", teamMemberHandler.CreateWeb)
a.DELETE("/teams/:team/members/:user", teamMemberHandler.DeleteWeb)
}
func registerCalDavRoutes(c *echo.Group) {
// Basic auth middleware
c.Use(middleware.BasicAuth(caldavBasicAuth))
// THIS is the entry point for caldav clients, otherwise lists will show up double
c.Any("", caldav.EntryHandler)
c.Any("/", caldav.EntryHandler)
c.Any("/principals/*/", caldav.PrincipalHandler)
c.Any("/lists", caldav.ListHandler)
c.Any("/lists/", caldav.ListHandler)
c.Any("/lists/:list", caldav.ListHandler)
c.Any("/lists/:list/", caldav.ListHandler)
c.Any("/lists/:list/:task", caldav.TaskHandler) // Mostly used for editing
}
func caldavBasicAuth(username, password string, c echo.Context) (bool, error) {
creds := &models.UserLogin{
Username: username,
Password: password,
}
u, err := models.CheckUserCredentials(creds)
if err != nil {
log.Log.Errorf("Error during basic auth for caldav: %v", err)
return false, nil
}
// Save the user in echo context for later use
c.Set("userBasicAuth", u)
return true, nil
}

View file

@ -1,6 +1,6 @@
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag at
// 2019-04-30 11:26:19.179895431 +0200 CEST m=+0.133585470
// 2019-05-22 19:24:37.734465408 +0200 CEST m=+0.660846954
package swagger
@ -14,7 +14,7 @@ import (
var doc = `{
"swagger": "2.0",
"info": {
"description": "This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. \u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + "`" + `Authorization: Bearer \u003cjwt-token\u003e` + "`" + `-header to authenticate successfully.\n\n**BasicAuth:** Only used when requesting tasks via caldav.\n\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
"description": "\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
"title": "Vikunja API",
"contact": {
"name": "General Vikunja contact",
@ -391,7 +391,7 @@ var doc = `{
"JWTKeyAuth": []
}
],
"description": "Returns a team by its ID.",
"description": "Returns a list by its ID.",
"consumes": [
"application/json"
],
@ -399,13 +399,13 @@ var doc = `{
"application/json"
],
"tags": [
"team"
"list"
],
"summary": "Gets one team",
"summary": "Gets one list",
"parameters": [
{
"type": "integer",
"description": "Team ID",
"description": "List ID",
"name": "id",
"in": "path",
"required": true
@ -413,14 +413,14 @@ var doc = `{
],
"responses": {
"200": {
"description": "The team",
"description": "The list",
"schema": {
"type": "object",
"$ref": "#/definitions/models.Team"
"$ref": "#/definitions/models.List"
}
},
"403": {
"description": "The user does not have access to the team",
"description": "The user does not have access to the list",
"schema": {
"type": "object",
"$ref": "#/definitions/code.vikunja.io.web.HTTPError"
@ -2381,37 +2381,6 @@ var doc = `{
}
}
},
"/tasks/caldav": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.",
"produces": [
"text/plain"
],
"tags": [
"task"
],
"summary": "CalDAV-readable format with all tasks as calendar events.",
"responses": {
"200": {
"description": "The caldav events.",
"schema": {
"type": "string"
}
},
"403": {
"description": "Unauthorized.",
"schema": {
"type": "string"
}
}
}
}
},
"/tasks/{id}": {
"post": {
"security": [
@ -3688,6 +3657,10 @@ var doc = `{
"description": "Whether a task is done or not.",
"type": "boolean"
},
"doneAt": {
"description": "The unix timestamp when a task was marked as done.",
"type": "integer"
},
"dueDate": {
"description": "A unix timestamp when the task is due.",
"type": "integer"
@ -3905,6 +3878,10 @@ var doc = `{
"description": "Whether a task is done or not.",
"type": "boolean"
},
"doneAt": {
"description": "The unix timestamp when a task was marked as done.",
"type": "integer"
},
"dueDate": {
"description": "A unix timestamp when the task is due.",
"type": "integer"

View file

@ -1,7 +1,7 @@
{
"swagger": "2.0",
"info": {
"description": "This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. \u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + \"`\" + `Authorization: Bearer \u003cjwt-token\u003e` + \"`\" + `-header to authenticate successfully.\n\n**BasicAuth:** Only used when requesting tasks via caldav.\n\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
"description": "\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
"title": "Vikunja API",
"contact": {
"name": "General Vikunja contact",
@ -378,7 +378,7 @@
"JWTKeyAuth": []
}
],
"description": "Returns a team by its ID.",
"description": "Returns a list by its ID.",
"consumes": [
"application/json"
],
@ -386,13 +386,13 @@
"application/json"
],
"tags": [
"team"
"list"
],
"summary": "Gets one team",
"summary": "Gets one list",
"parameters": [
{
"type": "integer",
"description": "Team ID",
"description": "List ID",
"name": "id",
"in": "path",
"required": true
@ -400,14 +400,14 @@
],
"responses": {
"200": {
"description": "The team",
"description": "The list",
"schema": {
"type": "object",
"$ref": "#/definitions/models.Team"
"$ref": "#/definitions/models.List"
}
},
"403": {
"description": "The user does not have access to the team",
"description": "The user does not have access to the list",
"schema": {
"type": "object",
"$ref": "#/definitions/code.vikunja.io/web.HTTPError"
@ -2368,37 +2368,6 @@
}
}
},
"/tasks/caldav": {
"get": {
"security": [
{
"BasicAuth": []
}
],
"description": "Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.",
"produces": [
"text/plain"
],
"tags": [
"task"
],
"summary": "CalDAV-readable format with all tasks as calendar events.",
"responses": {
"200": {
"description": "The caldav events.",
"schema": {
"type": "string"
}
},
"403": {
"description": "Unauthorized.",
"schema": {
"type": "string"
}
}
}
}
},
"/tasks/{id}": {
"post": {
"security": [
@ -3674,6 +3643,10 @@
"description": "Whether a task is done or not.",
"type": "boolean"
},
"doneAt": {
"description": "The unix timestamp when a task was marked as done.",
"type": "integer"
},
"dueDate": {
"description": "A unix timestamp when the task is due.",
"type": "integer"
@ -3891,6 +3864,10 @@
"description": "Whether a task is done or not.",
"type": "boolean"
},
"doneAt": {
"description": "The unix timestamp when a task was marked as done.",
"type": "integer"
},
"dueDate": {
"description": "A unix timestamp when the task is due.",
"type": "integer"

View file

@ -51,6 +51,9 @@ definitions:
done:
description: Whether a task is done or not.
type: boolean
doneAt:
description: The unix timestamp when a task was marked as done.
type: integer
dueDate:
description: A unix timestamp when the task is due.
type: integer
@ -223,6 +226,9 @@ definitions:
done:
description: Whether a task is done or not.
type: boolean
doneAt:
description: The unix timestamp when a task was marked as done.
type: integer
dueDate:
description: A unix timestamp when the task is due.
type: integer
@ -645,13 +651,7 @@ info:
email: hello@vikunja.io
name: General Vikunja contact
url: http://vikunja.io/en/contact/
description: |-
This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. <!-- ReDoc-Inject: <security-definitions> -->
# Authorization
**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + "`" + `Authorization: Bearer <jwt-token>` + "`" + `-header to authenticate successfully.
**BasicAuth:** Only used when requesting tasks via caldav.
<!-- ReDoc-Inject: <security-definitions> -->
description: '<!-- ReDoc-Inject: <security-definitions> -->'
license:
name: GPLv3
url: http://code.vikunja.io/api/src/branch/master/LICENSE
@ -935,9 +935,9 @@ paths:
get:
consumes:
- application/json
description: Returns a team by its ID.
description: Returns a list by its ID.
parameters:
- description: Team ID
- description: List ID
in: path
name: id
required: true
@ -946,12 +946,12 @@ paths:
- application/json
responses:
"200":
description: The team
description: The list
schema:
$ref: '#/definitions/models.Team'
$ref: '#/definitions/models.List'
type: object
"403":
description: The user does not have access to the team
description: The user does not have access to the list
schema:
$ref: '#/definitions/code.vikunja.io/web.HTTPError'
type: object
@ -962,9 +962,9 @@ paths:
type: object
security:
- JWTKeyAuth: []
summary: Gets one team
summary: Gets one list
tags:
- team
- list
post:
consumes:
- application/json
@ -2638,27 +2638,6 @@ paths:
summary: Update a bunch of tasks at once
tags:
- task
/tasks/caldav:
get:
description: Returns a calDAV-parsable format with all tasks as calendar events.
Only returns tasks with a due date. Also creates reminders when the task has
one.
produces:
- text/plain
responses:
"200":
description: The caldav events.
schema:
type: string
"403":
description: Unauthorized.
schema:
type: string
security:
- BasicAuth: []
summary: CalDAV-readable format with all tasks as calendar events.
tags:
- task
/teams:
get:
consumes:

14
vendor/github.com/beevik/etree/.travis.yml generated vendored Normal file
View file

@ -0,0 +1,14 @@
language: go
sudo: false
go:
- 1.11.x
- tip
matrix:
allow_failures:
- go: tip
script:
- go vet ./...
- go test -v ./...

10
vendor/github.com/beevik/etree/CONTRIBUTORS generated vendored Normal file
View file

@ -0,0 +1,10 @@
Brett Vickers (beevik)
Felix Geisendörfer (felixge)
Kamil Kisiel (kisielk)
Graham King (grahamking)
Matt Smith (ma314smith)
Michal Jemala (michaljemala)
Nicolas Piganeau (npiganeau)
Chris Brown (ccbrown)
Earncef Sequeira (earncef)
Gabriel de Labachelerie (wuzuf)

24
vendor/github.com/beevik/etree/LICENSE generated vendored Normal file
View file

@ -0,0 +1,24 @@
Copyright 2015-2019 Brett Vickers. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER ``AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

205
vendor/github.com/beevik/etree/README.md generated vendored Normal file
View file

@ -0,0 +1,205 @@
[![Build Status](https://travis-ci.org/beevik/etree.svg?branch=master)](https://travis-ci.org/beevik/etree)
[![GoDoc](https://godoc.org/github.com/beevik/etree?status.svg)](https://godoc.org/github.com/beevik/etree)
etree
=====
The etree package is a lightweight, pure go package that expresses XML in
the form of an element tree. Its design was inspired by the Python
[ElementTree](http://docs.python.org/2/library/xml.etree.elementtree.html)
module.
Some of the package's capabilities and features:
* Represents XML documents as trees of elements for easy traversal.
* Imports, serializes, modifies or creates XML documents from scratch.
* Writes and reads XML to/from files, byte slices, strings and io interfaces.
* Performs simple or complex searches with lightweight XPath-like query APIs.
* Auto-indents XML using spaces or tabs for better readability.
* Implemented in pure go; depends only on standard go libraries.
* Built on top of the go [encoding/xml](http://golang.org/pkg/encoding/xml)
package.
### Creating an XML document
The following example creates an XML document from scratch using the etree
package and outputs its indented contents to stdout.
```go
doc := etree.NewDocument()
doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
doc.CreateProcInst("xml-stylesheet", `type="text/xsl" href="style.xsl"`)
people := doc.CreateElement("People")
people.CreateComment("These are all known people")
jon := people.CreateElement("Person")
jon.CreateAttr("name", "Jon")
sally := people.CreateElement("Person")
sally.CreateAttr("name", "Sally")
doc.Indent(2)
doc.WriteTo(os.Stdout)
```
Output:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="style.xsl"?>
<People>
<!--These are all known people-->
<Person name="Jon"/>
<Person name="Sally"/>
</People>
```
### Reading an XML file
Suppose you have a file on disk called `bookstore.xml` containing the
following data:
```xml
<bookstore xmlns:p="urn:schemas-books-com:prices">
<book category="COOKING">
<title lang="en">Everyday Italian</title>
<author>Giada De Laurentiis</author>
<year>2005</year>
<p:price>30.00</p:price>
</book>
<book category="CHILDREN">
<title lang="en">Harry Potter</title>
<author>J K. Rowling</author>
<year>2005</year>
<p:price>29.99</p:price>
</book>
<book category="WEB">
<title lang="en">XQuery Kick Start</title>
<author>James McGovern</author>
<author>Per Bothner</author>
<author>Kurt Cagle</author>
<author>James Linn</author>
<author>Vaidyanathan Nagarajan</author>
<year>2003</year>
<p:price>49.99</p:price>
</book>
<book category="WEB">
<title lang="en">Learning XML</title>
<author>Erik T. Ray</author>
<year>2003</year>
<p:price>39.95</p:price>
</book>
</bookstore>
```
This code reads the file's contents into an etree document.
```go
doc := etree.NewDocument()
if err := doc.ReadFromFile("bookstore.xml"); err != nil {
panic(err)
}
```
You can also read XML from a string, a byte slice, or an `io.Reader`.
### Processing elements and attributes
This example illustrates several ways to access elements and attributes using
etree selection queries.
```go
root := doc.SelectElement("bookstore")
fmt.Println("ROOT element:", root.Tag)
for _, book := range root.SelectElements("book") {
fmt.Println("CHILD element:", book.Tag)
if title := book.SelectElement("title"); title != nil {
lang := title.SelectAttrValue("lang", "unknown")
fmt.Printf(" TITLE: %s (%s)\n", title.Text(), lang)
}
for _, attr := range book.Attr {
fmt.Printf(" ATTR: %s=%s\n", attr.Key, attr.Value)
}
}
```
Output:
```
ROOT element: bookstore
CHILD element: book
TITLE: Everyday Italian (en)
ATTR: category=COOKING
CHILD element: book
TITLE: Harry Potter (en)
ATTR: category=CHILDREN
CHILD element: book
TITLE: XQuery Kick Start (en)
ATTR: category=WEB
CHILD element: book
TITLE: Learning XML (en)
ATTR: category=WEB
```
### Path queries
This example uses etree's path functions to select all book titles that fall
into the category of 'WEB'. The double-slash prefix in the path causes the
search for book elements to occur recursively; book elements may appear at any
level of the XML hierarchy.
```go
for _, t := range doc.FindElements("//book[@category='WEB']/title") {
fmt.Println("Title:", t.Text())
}
```
Output:
```
Title: XQuery Kick Start
Title: Learning XML
```
This example finds the first book element under the root bookstore element and
outputs the tag and text of each of its child elements.
```go
for _, e := range doc.FindElements("./bookstore/book[1]/*") {
fmt.Printf("%s: %s\n", e.Tag, e.Text())
}
```
Output:
```
title: Everyday Italian
author: Giada De Laurentiis
year: 2005
price: 30.00
```
This example finds all books with a price of 49.99 and outputs their titles.
```go
path := etree.MustCompilePath("./bookstore/book[p:price='49.99']/title")
for _, e := range doc.FindElementsPath(path) {
fmt.Println(e.Text())
}
```
Output:
```
XQuery Kick Start
```
Note that this example uses the FindElementsPath function, which takes as an
argument a pre-compiled path object. Use precompiled paths when you plan to
search with the same path more than once.
### Other features
These are just a few examples of the things the etree package can do. See the
[documentation](http://godoc.org/github.com/beevik/etree) for a complete
description of its capabilities.
### Contributing
This project accepts contributions. Just fork the repo and submit a pull
request!

109
vendor/github.com/beevik/etree/RELEASE_NOTES.md generated vendored Normal file
View file

@ -0,0 +1,109 @@
Release v1.1.0
==============
**New Features**
* New attribute helpers.
* Added the `Element.SortAttrs` method, which lexicographically sorts an
element's attributes by key.
* New `ReadSettings` properties.
* Added `Entity` for the support of custom entity maps.
* New `WriteSettings` properties.
* Added `UseCRLF` to allow the output of CR-LF newlines instead of the
default LF newlines. This is useful on Windows systems.
* Additional support for text and CDATA sections.
* The `Element.Text` method now returns the concatenation of all consecutive
character data tokens immediately following an element's opening tag.
* Added `Element.SetCData` to replace the character data immediately
following an element's opening tag with a CDATA section.
* Added `Element.CreateCData` to create and add a CDATA section child
`CharData` token to an element.
* Added `Element.CreateText` to create and add a child text `CharData` token
to an element.
* Added `NewCData` to create a parentless CDATA section `CharData` token.
* Added `NewText` to create a parentless text `CharData`
token.
* Added `CharData.IsCData` to detect if the token contains a CDATA section.
* Added `CharData.IsWhitespace` to detect if the token contains whitespace
inserted by one of the document Indent functions.
* Modified `Element.SetText` so that it replaces a run of consecutive
character data tokens following the element's opening tag (instead of just
the first one).
* New "tail text" support.
* Added the `Element.Tail` method, which returns the text immediately
following an element's closing tag.
* Added the `Element.SetTail` method, which modifies the text immediately
following an element's closing tag.
* New element child insertion and removal methods.
* Added the `Element.InsertChildAt` method, which inserts a new child token
before the specified child token index.
* Added the `Element.RemoveChildAt` method, which removes the child token at
the specified child token index.
* New element and attribute queries.
* Added the `Element.Index` method, which returns the element's index within
its parent element's child token list.
* Added the `Element.NamespaceURI` method to return the namespace URI
associated with an element.
* Added the `Attr.NamespaceURI` method to return the namespace URI
associated with an element.
* Added the `Attr.Element` method to return the element that an attribute
belongs to.
* New Path filter functions.
* Added `[local-name()='val']` to keep elements whose unprefixed tag matches
the desired value.
* Added `[name()='val']` to keep elements whose full tag matches the desired
value.
* Added `[namespace-prefix()='val']` to keep elements whose namespace prefix
matches the desired value.
* Added `[namespace-uri()='val']` to keep elements whose namespace URI
matches the desired value.
**Bug Fixes**
* A default XML `CharSetReader` is now used to prevent failed parsing of XML
documents using certain encodings.
([Issue](https://github.com/beevik/etree/issues/53)).
* All characters are now properly escaped according to XML parsing rules.
([Issue](https://github.com/beevik/etree/issues/55)).
* The `Document.Indent` and `Document.IndentTabs` functions no longer insert
empty string `CharData` tokens.
**Deprecated**
* `Element`
* The `InsertChild` method is deprecated. Use `InsertChildAt` instead.
* The `CreateCharData` method is deprecated. Use `CreateText` instead.
* `CharData`
* The `NewCharData` method is deprecated. Use `NewText` instead.
Release v1.0.1
==============
**Changes**
* Added support for absolute etree Path queries. An absolute path begins with
`/` or `//` and begins its search from the element's document root.
* Added [`GetPath`](https://godoc.org/github.com/beevik/etree#Element.GetPath)
and [`GetRelativePath`](https://godoc.org/github.com/beevik/etree#Element.GetRelativePath)
functions to the [`Element`](https://godoc.org/github.com/beevik/etree#Element)
type.
**Breaking changes**
* A path starting with `//` is now interpreted as an absolute path.
Previously, it was interpreted as a relative path starting from the element
whose
[`FindElement`](https://godoc.org/github.com/beevik/etree#Element.FindElement)
method was called. To remain compatible with this release, all paths
prefixed with `//` should be prefixed with `.//` when called from any
element other than the document's root.
* [**edit 2/1/2019**]: Minor releases should not contain breaking changes.
Even though this breaking change was very minor, it was a mistake to include
it in this minor release. In the future, all breaking changes will be
limited to major releases (e.g., version 2.0.0).
Release v1.0.0
==============
Initial release.

1453
vendor/github.com/beevik/etree/etree.go generated vendored Normal file

File diff suppressed because it is too large Load diff

276
vendor/github.com/beevik/etree/helpers.go generated vendored Normal file
View file

@ -0,0 +1,276 @@
// Copyright 2015-2019 Brett Vickers.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package etree
import (
"bufio"
"io"
"strings"
"unicode/utf8"
)
// A simple stack
type stack struct {
data []interface{}
}
func (s *stack) empty() bool {
return len(s.data) == 0
}
func (s *stack) push(value interface{}) {
s.data = append(s.data, value)
}
func (s *stack) pop() interface{} {
value := s.data[len(s.data)-1]
s.data[len(s.data)-1] = nil
s.data = s.data[:len(s.data)-1]
return value
}
func (s *stack) peek() interface{} {
return s.data[len(s.data)-1]
}
// A fifo is a simple first-in-first-out queue.
type fifo struct {
data []interface{}
head, tail int
}
func (f *fifo) add(value interface{}) {
if f.len()+1 >= len(f.data) {
f.grow()
}
f.data[f.tail] = value
if f.tail++; f.tail == len(f.data) {
f.tail = 0
}
}
func (f *fifo) remove() interface{} {
value := f.data[f.head]
f.data[f.head] = nil
if f.head++; f.head == len(f.data) {
f.head = 0
}
return value
}
func (f *fifo) len() int {
if f.tail >= f.head {
return f.tail - f.head
}
return len(f.data) - f.head + f.tail
}
func (f *fifo) grow() {
c := len(f.data) * 2
if c == 0 {
c = 4
}
buf, count := make([]interface{}, c), f.len()
if f.tail >= f.head {
copy(buf[0:count], f.data[f.head:f.tail])
} else {
hindex := len(f.data) - f.head
copy(buf[0:hindex], f.data[f.head:])
copy(buf[hindex:count], f.data[:f.tail])
}
f.data, f.head, f.tail = buf, 0, count
}
// countReader implements a proxy reader that counts the number of
// bytes read from its encapsulated reader.
type countReader struct {
r io.Reader
bytes int64
}
func newCountReader(r io.Reader) *countReader {
return &countReader{r: r}
}
func (cr *countReader) Read(p []byte) (n int, err error) {
b, err := cr.r.Read(p)
cr.bytes += int64(b)
return b, err
}
// countWriter implements a proxy writer that counts the number of
// bytes written by its encapsulated writer.
type countWriter struct {
w io.Writer
bytes int64
}
func newCountWriter(w io.Writer) *countWriter {
return &countWriter{w: w}
}
func (cw *countWriter) Write(p []byte) (n int, err error) {
b, err := cw.w.Write(p)
cw.bytes += int64(b)
return b, err
}
// isWhitespace returns true if the byte slice contains only
// whitespace characters.
func isWhitespace(s string) bool {
for i := 0; i < len(s); i++ {
if c := s[i]; c != ' ' && c != '\t' && c != '\n' && c != '\r' {
return false
}
}
return true
}
// spaceMatch returns true if namespace a is the empty string
// or if namespace a equals namespace b.
func spaceMatch(a, b string) bool {
switch {
case a == "":
return true
default:
return a == b
}
}
// spaceDecompose breaks a namespace:tag identifier at the ':'
// and returns the two parts.
func spaceDecompose(str string) (space, key string) {
colon := strings.IndexByte(str, ':')
if colon == -1 {
return "", str
}
return str[:colon], str[colon+1:]
}
// Strings used by indentCRLF and indentLF
const (
indentSpaces = "\r\n "
indentTabs = "\r\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"
)
// indentCRLF returns a CRLF newline followed by n copies of the first
// non-CRLF character in the source string.
func indentCRLF(n int, source string) string {
switch {
case n < 0:
return source[:2]
case n < len(source)-1:
return source[:n+2]
default:
return source + strings.Repeat(source[2:3], n-len(source)+2)
}
}
// indentLF returns a LF newline followed by n copies of the first non-LF
// character in the source string.
func indentLF(n int, source string) string {
switch {
case n < 0:
return source[1:2]
case n < len(source)-1:
return source[1 : n+2]
default:
return source[1:] + strings.Repeat(source[2:3], n-len(source)+2)
}
}
// nextIndex returns the index of the next occurrence of sep in s,
// starting from offset. It returns -1 if the sep string is not found.
func nextIndex(s, sep string, offset int) int {
switch i := strings.Index(s[offset:], sep); i {
case -1:
return -1
default:
return offset + i
}
}
// isInteger returns true if the string s contains an integer.
func isInteger(s string) bool {
for i := 0; i < len(s); i++ {
if (s[i] < '0' || s[i] > '9') && !(i == 0 && s[i] == '-') {
return false
}
}
return true
}
type escapeMode byte
const (
escapeNormal escapeMode = iota
escapeCanonicalText
escapeCanonicalAttr
)
// escapeString writes an escaped version of a string to the writer.
func escapeString(w *bufio.Writer, s string, m escapeMode) {
var esc []byte
last := 0
for i := 0; i < len(s); {
r, width := utf8.DecodeRuneInString(s[i:])
i += width
switch r {
case '&':
esc = []byte("&amp;")
case '<':
esc = []byte("&lt;")
case '>':
if m == escapeCanonicalAttr {
continue
}
esc = []byte("&gt;")
case '\'':
if m != escapeNormal {
continue
}
esc = []byte("&apos;")
case '"':
if m == escapeCanonicalText {
continue
}
esc = []byte("&quot;")
case '\t':
if m != escapeCanonicalAttr {
continue
}
esc = []byte("&#x9;")
case '\n':
if m != escapeCanonicalAttr {
continue
}
esc = []byte("&#xA;")
case '\r':
if m == escapeNormal {
continue
}
esc = []byte("&#xD;")
default:
if !isInCharacterRange(r) || (r == 0xFFFD && width == 1) {
esc = []byte("\uFFFD")
break
}
continue
}
w.WriteString(s[last : i-width])
w.Write(esc)
last = i
}
w.WriteString(s[last:])
}
func isInCharacterRange(r rune) bool {
return r == 0x09 ||
r == 0x0A ||
r == 0x0D ||
r >= 0x20 && r <= 0xD7FF ||
r >= 0xE000 && r <= 0xFFFD ||
r >= 0x10000 && r <= 0x10FFFF
}

582
vendor/github.com/beevik/etree/path.go generated vendored Normal file
View file

@ -0,0 +1,582 @@
// Copyright 2015-2019 Brett Vickers.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package etree
import (
"strconv"
"strings"
)
/*
A Path is a string that represents a search path through an etree starting
from the document root or an arbitrary element. Paths are used with the
Element object's Find* methods to locate and return desired elements.
A Path consists of a series of slash-separated "selectors", each of which may
be modified by one or more bracket-enclosed "filters". Selectors are used to
traverse the etree from element to element, while filters are used to narrow
the list of candidate elements at each node.
Although etree Path strings are similar to XPath strings
(https://www.w3.org/TR/1999/REC-xpath-19991116/), they have a more limited set
of selectors and filtering options.
The following selectors are supported by etree Path strings:
. Select the current element.
.. Select the parent of the current element.
* Select all child elements of the current element.
/ Select the root element when used at the start of a path.
// Select all descendants of the current element.
tag Select all child elements with a name matching the tag.
The following basic filters are supported by etree Path strings:
[@attrib] Keep elements with an attribute named attrib.
[@attrib='val'] Keep elements with an attribute named attrib and value matching val.
[tag] Keep elements with a child element named tag.
[tag='val'] Keep elements with a child element named tag and text matching val.
[n] Keep the n-th element, where n is a numeric index starting from 1.
The following function filters are also supported:
[text()] Keep elements with non-empty text.
[text()='val'] Keep elements whose text matches val.
[local-name()='val'] Keep elements whose un-prefixed tag matches val.
[name()='val'] Keep elements whose full tag exactly matches val.
[namespace-prefix()='val'] Keep elements whose namespace prefix matches val.
[namespace-uri()='val'] Keep elements whose namespace URI matches val.
Here are some examples of Path strings:
- Select the bookstore child element of the root element:
/bookstore
- Beginning from the root element, select the title elements of all
descendant book elements having a 'category' attribute of 'WEB':
//book[@category='WEB']/title
- Beginning from the current element, select the first descendant
book element with a title child element containing the text 'Great
Expectations':
.//book[title='Great Expectations'][1]
- Beginning from the current element, select all child elements of
book elements with an attribute 'language' set to 'english':
./book/*[@language='english']
- Beginning from the current element, select all child elements of
book elements containing the text 'special':
./book/*[text()='special']
- Beginning from the current element, select all descendant book
elements whose title child element has a 'language' attribute of 'french':
.//book/title[@language='french']/..
- Beginning from the current element, select all book elements
belonging to the http://www.w3.org/TR/html4/ namespace:
.//book[namespace-uri()='http://www.w3.org/TR/html4/']
*/
type Path struct {
segments []segment
}
// ErrPath is returned by path functions when an invalid etree path is provided.
type ErrPath string
// Error returns the string describing a path error.
func (err ErrPath) Error() string {
return "etree: " + string(err)
}
// CompilePath creates an optimized version of an XPath-like string that
// can be used to query elements in an element tree.
func CompilePath(path string) (Path, error) {
var comp compiler
segments := comp.parsePath(path)
if comp.err != ErrPath("") {
return Path{nil}, comp.err
}
return Path{segments}, nil
}
// MustCompilePath creates an optimized version of an XPath-like string that
// can be used to query elements in an element tree. Panics if an error
// occurs. Use this function to create Paths when you know the path is
// valid (i.e., if it's hard-coded).
func MustCompilePath(path string) Path {
p, err := CompilePath(path)
if err != nil {
panic(err)
}
return p
}
// A segment is a portion of a path between "/" characters.
// It contains one selector and zero or more [filters].
type segment struct {
sel selector
filters []filter
}
func (seg *segment) apply(e *Element, p *pather) {
seg.sel.apply(e, p)
for _, f := range seg.filters {
f.apply(p)
}
}
// A selector selects XML elements for consideration by the
// path traversal.
type selector interface {
apply(e *Element, p *pather)
}
// A filter pares down a list of candidate XML elements based
// on a path filter in [brackets].
type filter interface {
apply(p *pather)
}
// A pather is helper object that traverses an element tree using
// a Path object. It collects and deduplicates all elements matching
// the path query.
type pather struct {
queue fifo
results []*Element
inResults map[*Element]bool
candidates []*Element
scratch []*Element // used by filters
}
// A node represents an element and the remaining path segments that
// should be applied against it by the pather.
type node struct {
e *Element
segments []segment
}
func newPather() *pather {
return &pather{
results: make([]*Element, 0),
inResults: make(map[*Element]bool),
candidates: make([]*Element, 0),
scratch: make([]*Element, 0),
}
}
// traverse follows the path from the element e, collecting
// and then returning all elements that match the path's selectors
// and filters.
func (p *pather) traverse(e *Element, path Path) []*Element {
for p.queue.add(node{e, path.segments}); p.queue.len() > 0; {
p.eval(p.queue.remove().(node))
}
return p.results
}
// eval evalutes the current path node by applying the remaining
// path's selector rules against the node's element.
func (p *pather) eval(n node) {
p.candidates = p.candidates[0:0]
seg, remain := n.segments[0], n.segments[1:]
seg.apply(n.e, p)
if len(remain) == 0 {
for _, c := range p.candidates {
if in := p.inResults[c]; !in {
p.inResults[c] = true
p.results = append(p.results, c)
}
}
} else {
for _, c := range p.candidates {
p.queue.add(node{c, remain})
}
}
}
// A compiler generates a compiled path from a path string.
type compiler struct {
err ErrPath
}
// parsePath parses an XPath-like string describing a path
// through an element tree and returns a slice of segment
// descriptors.
func (c *compiler) parsePath(path string) []segment {
// If path ends with //, fix it
if strings.HasSuffix(path, "//") {
path = path + "*"
}
var segments []segment
// Check for an absolute path
if strings.HasPrefix(path, "/") {
segments = append(segments, segment{new(selectRoot), []filter{}})
path = path[1:]
}
// Split path into segments
for _, s := range splitPath(path) {
segments = append(segments, c.parseSegment(s))
if c.err != ErrPath("") {
break
}
}
return segments
}
func splitPath(path string) []string {
pieces := make([]string, 0)
start := 0
inquote := false
for i := 0; i+1 <= len(path); i++ {
if path[i] == '\'' {
inquote = !inquote
} else if path[i] == '/' && !inquote {
pieces = append(pieces, path[start:i])
start = i + 1
}
}
return append(pieces, path[start:])
}
// parseSegment parses a path segment between / characters.
func (c *compiler) parseSegment(path string) segment {
pieces := strings.Split(path, "[")
seg := segment{
sel: c.parseSelector(pieces[0]),
filters: []filter{},
}
for i := 1; i < len(pieces); i++ {
fpath := pieces[i]
if fpath[len(fpath)-1] != ']' {
c.err = ErrPath("path has invalid filter [brackets].")
break
}
seg.filters = append(seg.filters, c.parseFilter(fpath[:len(fpath)-1]))
}
return seg
}
// parseSelector parses a selector at the start of a path segment.
func (c *compiler) parseSelector(path string) selector {
switch path {
case ".":
return new(selectSelf)
case "..":
return new(selectParent)
case "*":
return new(selectChildren)
case "":
return new(selectDescendants)
default:
return newSelectChildrenByTag(path)
}
}
var fnTable = map[string]struct {
hasFn func(e *Element) bool
getValFn func(e *Element) string
}{
"local-name": {nil, (*Element).name},
"name": {nil, (*Element).FullTag},
"namespace-prefix": {nil, (*Element).namespacePrefix},
"namespace-uri": {nil, (*Element).NamespaceURI},
"text": {(*Element).hasText, (*Element).Text},
}
// parseFilter parses a path filter contained within [brackets].
func (c *compiler) parseFilter(path string) filter {
if len(path) == 0 {
c.err = ErrPath("path contains an empty filter expression.")
return nil
}
// Filter contains [@attr='val'], [fn()='val'], or [tag='val']?
eqindex := strings.Index(path, "='")
if eqindex >= 0 {
rindex := nextIndex(path, "'", eqindex+2)
if rindex != len(path)-1 {
c.err = ErrPath("path has mismatched filter quotes.")
return nil
}
key := path[:eqindex]
value := path[eqindex+2 : rindex]
switch {
case key[0] == '@':
return newFilterAttrVal(key[1:], value)
case strings.HasSuffix(key, "()"):
fn := key[:len(key)-2]
if t, ok := fnTable[fn]; ok && t.getValFn != nil {
return newFilterFuncVal(t.getValFn, value)
}
c.err = ErrPath("path has unknown function " + fn)
return nil
default:
return newFilterChildText(key, value)
}
}
// Filter contains [@attr], [N], [tag] or [fn()]
switch {
case path[0] == '@':
return newFilterAttr(path[1:])
case strings.HasSuffix(path, "()"):
fn := path[:len(path)-2]
if t, ok := fnTable[fn]; ok && t.hasFn != nil {
return newFilterFunc(t.hasFn)
}
c.err = ErrPath("path has unknown function " + fn)
return nil
case isInteger(path):
pos, _ := strconv.Atoi(path)
switch {
case pos > 0:
return newFilterPos(pos - 1)
default:
return newFilterPos(pos)
}
default:
return newFilterChild(path)
}
}
// selectSelf selects the current element into the candidate list.
type selectSelf struct{}
func (s *selectSelf) apply(e *Element, p *pather) {
p.candidates = append(p.candidates, e)
}
// selectRoot selects the element's root node.
type selectRoot struct{}
func (s *selectRoot) apply(e *Element, p *pather) {
root := e
for root.parent != nil {
root = root.parent
}
p.candidates = append(p.candidates, root)
}
// selectParent selects the element's parent into the candidate list.
type selectParent struct{}
func (s *selectParent) apply(e *Element, p *pather) {
if e.parent != nil {
p.candidates = append(p.candidates, e.parent)
}
}
// selectChildren selects the element's child elements into the
// candidate list.
type selectChildren struct{}
func (s *selectChildren) apply(e *Element, p *pather) {
for _, c := range e.Child {
if c, ok := c.(*Element); ok {
p.candidates = append(p.candidates, c)
}
}
}
// selectDescendants selects all descendant child elements
// of the element into the candidate list.
type selectDescendants struct{}
func (s *selectDescendants) apply(e *Element, p *pather) {
var queue fifo
for queue.add(e); queue.len() > 0; {
e := queue.remove().(*Element)
p.candidates = append(p.candidates, e)
for _, c := range e.Child {
if c, ok := c.(*Element); ok {
queue.add(c)
}
}
}
}
// selectChildrenByTag selects into the candidate list all child
// elements of the element having the specified tag.
type selectChildrenByTag struct {
space, tag string
}
func newSelectChildrenByTag(path string) *selectChildrenByTag {
s, l := spaceDecompose(path)
return &selectChildrenByTag{s, l}
}
func (s *selectChildrenByTag) apply(e *Element, p *pather) {
for _, c := range e.Child {
if c, ok := c.(*Element); ok && spaceMatch(s.space, c.Space) && s.tag == c.Tag {
p.candidates = append(p.candidates, c)
}
}
}
// filterPos filters the candidate list, keeping only the
// candidate at the specified index.
type filterPos struct {
index int
}
func newFilterPos(pos int) *filterPos {
return &filterPos{pos}
}
func (f *filterPos) apply(p *pather) {
if f.index >= 0 {
if f.index < len(p.candidates) {
p.scratch = append(p.scratch, p.candidates[f.index])
}
} else {
if -f.index <= len(p.candidates) {
p.scratch = append(p.scratch, p.candidates[len(p.candidates)+f.index])
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterAttr filters the candidate list for elements having
// the specified attribute.
type filterAttr struct {
space, key string
}
func newFilterAttr(str string) *filterAttr {
s, l := spaceDecompose(str)
return &filterAttr{s, l}
}
func (f *filterAttr) apply(p *pather) {
for _, c := range p.candidates {
for _, a := range c.Attr {
if spaceMatch(f.space, a.Space) && f.key == a.Key {
p.scratch = append(p.scratch, c)
break
}
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterAttrVal filters the candidate list for elements having
// the specified attribute with the specified value.
type filterAttrVal struct {
space, key, val string
}
func newFilterAttrVal(str, value string) *filterAttrVal {
s, l := spaceDecompose(str)
return &filterAttrVal{s, l, value}
}
func (f *filterAttrVal) apply(p *pather) {
for _, c := range p.candidates {
for _, a := range c.Attr {
if spaceMatch(f.space, a.Space) && f.key == a.Key && f.val == a.Value {
p.scratch = append(p.scratch, c)
break
}
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterFunc filters the candidate list for elements satisfying a custom
// boolean function.
type filterFunc struct {
fn func(e *Element) bool
}
func newFilterFunc(fn func(e *Element) bool) *filterFunc {
return &filterFunc{fn}
}
func (f *filterFunc) apply(p *pather) {
for _, c := range p.candidates {
if f.fn(c) {
p.scratch = append(p.scratch, c)
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterFuncVal filters the candidate list for elements containing a value
// matching the result of a custom function.
type filterFuncVal struct {
fn func(e *Element) string
val string
}
func newFilterFuncVal(fn func(e *Element) string, value string) *filterFuncVal {
return &filterFuncVal{fn, value}
}
func (f *filterFuncVal) apply(p *pather) {
for _, c := range p.candidates {
if f.fn(c) == f.val {
p.scratch = append(p.scratch, c)
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterChild filters the candidate list for elements having
// a child element with the specified tag.
type filterChild struct {
space, tag string
}
func newFilterChild(str string) *filterChild {
s, l := spaceDecompose(str)
return &filterChild{s, l}
}
func (f *filterChild) apply(p *pather) {
for _, c := range p.candidates {
for _, cc := range c.Child {
if cc, ok := cc.(*Element); ok &&
spaceMatch(f.space, cc.Space) &&
f.tag == cc.Tag {
p.scratch = append(p.scratch, c)
}
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}
// filterChildText filters the candidate list for elements having
// a child element with the specified tag and text.
type filterChildText struct {
space, tag, text string
}
func newFilterChildText(str, text string) *filterChildText {
s, l := spaceDecompose(str)
return &filterChildText{s, l, text}
}
func (f *filterChildText) apply(p *pather) {
for _, c := range p.candidates {
for _, cc := range c.Child {
if cc, ok := cc.(*Element); ok &&
spaceMatch(f.space, cc.Space) &&
f.tag == cc.Tag &&
f.text == cc.Text() {
p.scratch = append(p.scratch, c)
}
}
}
p.candidates, p.scratch = p.scratch, p.candidates[0:0]
}

View file

@ -170,6 +170,8 @@ const (
charsetUTF8 = "charset=UTF-8"
// PROPFIND Method can be used on collection and property resources.
PROPFIND = "PROPFIND"
// REPORT Method can be used to get information about a resource, see rfc 3253
REPORT = "REPORT"
)
// Headers
@ -251,6 +253,7 @@ var (
PROPFIND,
http.MethodPut,
http.MethodTrace,
REPORT,
}
)

View file

@ -33,6 +33,7 @@ type (
propfind HandlerFunc
put HandlerFunc
trace HandlerFunc
report HandlerFunc
}
)
@ -247,6 +248,8 @@ func (n *node) addHandler(method string, h HandlerFunc) {
n.methodHandler.put = h
case http.MethodTrace:
n.methodHandler.trace = h
case REPORT:
n.methodHandler.report = h
}
}
@ -272,6 +275,8 @@ func (n *node) findHandler(method string) HandlerFunc {
return n.methodHandler.put
case http.MethodTrace:
return n.methodHandler.trace
case REPORT:
return n.methodHandler.report
default:
return nil
}

17
vendor/github.com/laurent22/ical-go/ical/calendar.go generated vendored Normal file
View file

@ -0,0 +1,17 @@
package ical
type Calendar struct {
Items []CalendarEvent
}
func (this *Calendar) Serialize() string {
serializer := calSerializer{
calendar: this,
buffer: new(strBuffer),
}
return serializer.serialize()
}
func (this *Calendar) ToICS() string {
return this.Serialize()
}

View file

@ -0,0 +1,50 @@
package ical
import (
"time"
)
type CalendarEvent struct {
Id string
Summary string
Description string
Location string
CreatedAtUTC *time.Time
ModifiedAtUTC *time.Time
StartAt *time.Time
EndAt *time.Time
}
func (this *CalendarEvent) StartAtUTC() *time.Time {
return inUTC(this.StartAt)
}
func (this *CalendarEvent) EndAtUTC() *time.Time {
return inUTC(this.EndAt)
}
func (this *CalendarEvent) Serialize() string {
buffer := new(strBuffer)
return this.serializeWithBuffer(buffer)
}
func (this *CalendarEvent) ToICS() string {
return this.Serialize()
}
func (this *CalendarEvent) serializeWithBuffer(buffer *strBuffer) string {
serializer := calEventSerializer{
event: this,
buffer: buffer,
}
return serializer.serialize()
}
func inUTC(t *time.Time) *time.Time {
if t == nil {
return nil
}
tUTC := t.UTC()
return &tUTC
}

18
vendor/github.com/laurent22/ical-go/ical/lib.go generated vendored Normal file
View file

@ -0,0 +1,18 @@
package ical
import (
"fmt"
"bytes"
)
type strBuffer struct {
buffer bytes.Buffer
}
func (b *strBuffer) Write(format string, elem ...interface{}) {
b.buffer.WriteString(fmt.Sprintf(format, elem...))
}
func (b *strBuffer) String() string {
return b.buffer.String()
}

168
vendor/github.com/laurent22/ical-go/ical/node.go generated vendored Normal file
View file

@ -0,0 +1,168 @@
package ical
import (
"time"
"regexp"
"strconv"
)
type Node struct {
Name string
Value string
Type int // 1 = Object, 0 = Name/Value
Parameters map[string]string
Children []*Node
}
func (this *Node) ChildrenByName(name string) []*Node {
var output []*Node
for _, child := range this.Children {
if child.Name == name {
output = append(output, child)
}
}
return output
}
func (this *Node) ChildByName(name string) *Node {
for _, child := range this.Children {
if child.Name == name {
return child
}
}
return nil
}
func (this *Node) PropString(name string, defaultValue string) string {
for _, child := range this.Children {
if child.Name == name {
return child.Value
}
}
return defaultValue
}
func (this *Node) PropDate(name string, defaultValue time.Time) time.Time {
node := this.ChildByName(name)
if node == nil { return defaultValue }
tzid := node.Parameter("TZID", "")
var output time.Time
var err error
if tzid != "" {
loc, err := time.LoadLocation(tzid)
if err != nil { panic(err) }
output, err = time.ParseInLocation("20060102T150405", node.Value, loc)
} else {
output, err = time.Parse("20060102T150405Z", node.Value)
}
if err != nil { panic(err) }
return output
}
func (this *Node) PropDuration(name string) time.Duration {
durStr := this.PropString(name, "")
if durStr == "" {
return time.Duration(0)
}
durRgx := regexp.MustCompile("PT(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)S)?")
matches := durRgx.FindStringSubmatch(durStr)
if len(matches) != 4 {
return time.Duration(0)
}
strToDuration := func(value string) time.Duration {
d := 0
if value != "" {
d, _ = strconv.Atoi(value)
}
return time.Duration(d)
}
hours := strToDuration(matches[1])
min := strToDuration(matches[2])
sec := strToDuration(matches[3])
return hours * time.Hour + min * time.Minute + sec * time.Second
}
func (this *Node) PropInt(name string, defaultValue int) int {
n := this.PropString(name, "")
if n == "" { return defaultValue }
output, err := strconv.Atoi(n)
if err != nil { panic(err) }
return output
}
func (this *Node) DigProperty(propPath... string) (string, bool) {
return this.dig("prop", propPath...)
}
func (this *Node) Parameter(name string, defaultValue string) string {
if len(this.Parameters) <= 0 { return defaultValue }
v, ok := this.Parameters[name]
if !ok { return defaultValue }
return v
}
func (this *Node) DigParameter(paramPath... string) (string, bool) {
return this.dig("param", paramPath...)
}
// Digs a value based on a given value path.
// valueType: can be "param" or "prop".
// valuePath: the path to access the value.
// Returns ("", false) when not found or (value, true) when found.
//
// Example:
// dig("param", "VCALENDAR", "VEVENT", "DTEND", "TYPE") -> It will search for "VCALENDAR" node,
// then a "VEVENT" node, then a "DTEND" note, then finally the "TYPE" param.
func (this *Node) dig(valueType string, valuePath... string) (string, bool) {
current := this
lastIndex := len(valuePath) - 1
for _, v := range valuePath[:lastIndex] {
current = current.ChildByName(v)
if current == nil {
return "", false
}
}
target := valuePath[lastIndex]
value := ""
if valueType == "param" {
value = current.Parameter(target, "")
} else if valueType == "prop" {
value = current.PropString(target, "")
}
if value == "" {
return "", false
}
return value, true
}
func (this *Node) String() string {
s := ""
if this.Type == 1 {
s += "===== " + this.Name
s += "\n"
} else {
s += this.Name
s += ":" + this.Value
s += "\n"
}
for _, child := range this.Children {
s += child.String()
}
if this.Type == 1 {
s += "===== /" + this.Name
s += "\n"
}
return s
}

106
vendor/github.com/laurent22/ical-go/ical/parsers.go generated vendored Normal file
View file

@ -0,0 +1,106 @@
package ical
import (
"log"
"errors"
"regexp"
"strings"
)
func ParseCalendar(data string) (*Node, error) {
r := regexp.MustCompile("([\r|\t| ]*\n[\r|\t| ]*)+")
lines := r.Split(strings.TrimSpace(data), -1)
node, _, err, _ := parseCalendarNode(lines, 0)
return node, err
}
func parseCalendarNode(lines []string, lineIndex int) (*Node, bool, error, int) {
line := strings.TrimSpace(lines[lineIndex])
_ = log.Println
colonIndex := strings.Index(line, ":")
if colonIndex <= 0 {
return nil, false, errors.New("Invalid value/pair: " + line), lineIndex + 1
}
name := line[0:colonIndex]
splitted := strings.Split(name, ";")
var parameters map[string]string
if len(splitted) >= 2 {
name = splitted[0]
parameters = make(map[string]string)
for i := 1; i < len(splitted); i++ {
p := strings.Split(splitted[i], "=")
if len(p) != 2 { panic("Invalid parameter format: " + name) }
parameters[p[0]] = p[1]
}
}
value := line[colonIndex+1:len(line)]
if name == "BEGIN" {
node := new(Node)
node.Name = value
node.Type = 1
lineIndex = lineIndex + 1
for {
child, finished, _, newLineIndex := parseCalendarNode(lines, lineIndex)
if finished {
return node, false, nil, newLineIndex
} else {
if child != nil {
node.Children = append(node.Children, child)
}
lineIndex = newLineIndex
}
}
} else if name == "END" {
return nil, true, nil, lineIndex + 1
} else {
node := new(Node)
node.Name = name
if name == "DESCRIPTION" || name == "SUMMARY" {
text, newLineIndex := parseTextType(lines, lineIndex)
node.Value = text
node.Parameters = parameters
return node, false, nil, newLineIndex
} else {
node.Value = value
node.Parameters = parameters
return node, false, nil, lineIndex + 1
}
}
panic("Unreachable")
return nil, false, nil, lineIndex + 1
}
func parseTextType(lines []string, lineIndex int) (string, int) {
line := lines[lineIndex]
colonIndex := strings.Index(line, ":")
output := strings.TrimSpace(line[colonIndex+1:len(line)])
lineIndex++
for {
line := lines[lineIndex]
if line == "" || line[0] != ' ' {
return unescapeTextType(output), lineIndex
}
output += line[1:len(line)]
lineIndex++
}
return unescapeTextType(output), lineIndex
}
func escapeTextType(input string) string {
output := strings.Replace(input, "\\", "\\\\", -1)
output = strings.Replace(output, ";", "\\;", -1)
output = strings.Replace(output, ",", "\\,", -1)
output = strings.Replace(output, "\n", "\\n", -1)
return output
}
func unescapeTextType(s string) string {
s = strings.Replace(s, "\\;", ";", -1)
s = strings.Replace(s, "\\,", ",", -1)
s = strings.Replace(s, "\\n", "\n", -1)
s = strings.Replace(s, "\\\\", "\\", -1)
return s
}

View file

@ -0,0 +1,9 @@
package ical
const (
VCALENDAR = "VCALENDAR"
VEVENT = "VEVENT"
DTSTART = "DTSTART"
DTEND = "DTEND"
DURATION = "DURATION"
)

121
vendor/github.com/laurent22/ical-go/ical/serializers.go generated vendored Normal file
View file

@ -0,0 +1,121 @@
package ical
import (
"time"
"strings"
)
type calSerializer struct {
calendar *Calendar
buffer *strBuffer
}
func (this *calSerializer) serialize() string {
this.serializeCalendar()
return strings.TrimSpace(this.buffer.String())
}
func (this *calSerializer) serializeCalendar() {
this.begin()
this.version()
this.items()
this.end()
}
func (this *calSerializer) begin() {
this.buffer.Write("BEGIN:VCALENDAR\n")
}
func (this *calSerializer) end() {
this.buffer.Write("END:VCALENDAR\n")
}
func (this *calSerializer) version() {
this.buffer.Write("VERSION:2.0\n")
}
func (this *calSerializer) items() {
for _, item := range this.calendar.Items {
item.serializeWithBuffer(this.buffer)
}
}
type calEventSerializer struct {
event *CalendarEvent
buffer *strBuffer
}
const (
eventSerializerTimeFormat = "20060102T150405Z"
)
func (this *calEventSerializer) serialize() string {
this.serializeEvent()
return strings.TrimSpace(this.buffer.String())
}
func (this *calEventSerializer) serializeEvent() {
this.begin()
this.uid()
this.created()
this.lastModified()
this.dtstart()
this.dtend()
this.summary()
this.description()
this.location()
this.end()
}
func (this *calEventSerializer) begin() {
this.buffer.Write("BEGIN:VEVENT\n")
}
func (this *calEventSerializer) end() {
this.buffer.Write("END:VEVENT\n")
}
func (this *calEventSerializer) uid() {
this.serializeStringProp("UID", this.event.Id)
}
func (this *calEventSerializer) summary() {
this.serializeStringProp("SUMMARY", this.event.Summary)
}
func (this *calEventSerializer) description() {
this.serializeStringProp("DESCRIPTION", this.event.Description)
}
func (this *calEventSerializer) location() {
this.serializeStringProp("LOCATION", this.event.Location)
}
func (this *calEventSerializer) dtstart() {
this.serializeTimeProp("DTSTART", this.event.StartAtUTC())
}
func (this *calEventSerializer) dtend() {
this.serializeTimeProp("DTEND", this.event.EndAtUTC())
}
func (this *calEventSerializer) created() {
this.serializeTimeProp("CREATED", this.event.CreatedAtUTC)
}
func (this *calEventSerializer) lastModified() {
this.serializeTimeProp("LAST-MODIFIED", this.event.ModifiedAtUTC)
}
func (this *calEventSerializer) serializeStringProp(name, value string) {
if value != "" {
escapedValue := escapeTextType(value)
this.buffer.Write("%s:%s\n", name, escapedValue)
}
}
func (this *calEventSerializer) serializeTimeProp(name string, value *time.Time) {
if value != nil {
this.buffer.Write("%s:%s\n", name, value.Format(eventSerializerTimeFormat))
}
}

94
vendor/github.com/laurent22/ical-go/ical/todo.go generated vendored Normal file
View file

@ -0,0 +1,94 @@
package ical
// import (
// "time"
// "strconv"
// "strings"
// )
//
// func TodoFromNode(node *Node) Todo {
// if node.Name != "VTODO" { panic("Node is not a VTODO") }
//
// var todo Todo
// todo.SetId(node.PropString("UID", ""))
// todo.SetSummary(node.PropString("SUMMARY", ""))
// todo.SetDescription(node.PropString("DESCRIPTION", ""))
// todo.SetDueDate(node.PropDate("DUE", time.Time{}))
// //todo.SetAlarmDate(this.TimestampBytesToTime(reminderDate))
// todo.SetCreatedDate(node.PropDate("CREATED", time.Time{}))
// todo.SetModifiedDate(node.PropDate("DTSTAMP", time.Time{}))
// todo.SetPriority(node.PropInt("PRIORITY", 0))
// todo.SetPercentComplete(node.PropInt("PERCENT-COMPLETE", 0))
// return todo
// }
//
// type Todo struct {
// CalendarItem
// dueDate time.Time
// }
//
// func (this *Todo) SetDueDate(v time.Time) { this.dueDate = v }
// func (this *Todo) DueDate() time.Time { return this.dueDate }
//
// func (this *Todo) ICalString(target string) string {
// s := "BEGIN:VTODO\n"
//
// if target == "macTodo" {
// status := "NEEDS-ACTION"
// if this.PercentComplete() == 100 {
// status = "COMPLETED"
// }
// s += "STATUS:" + status + "\n"
// }
//
// s += encodeDateProperty("CREATED", this.CreatedDate()) + "\n"
// s += "UID:" + this.Id() + "\n"
// s += "SUMMARY:" + escapeTextType(this.Summary()) + "\n"
// if this.PercentComplete() == 100 && !this.CompletedDate().IsZero() {
// s += encodeDateProperty("COMPLETED", this.CompletedDate()) + "\n"
// }
// s += encodeDateProperty("DTSTAMP", this.ModifiedDate()) + "\n"
// if this.Priority() != 0 {
// s += "PRIORITY:" + strconv.Itoa(this.Priority()) + "\n"
// }
// if this.PercentComplete() != 0 {
// s += "PERCENT-COMPLETE:" + strconv.Itoa(this.PercentComplete()) + "\n"
// }
// if target == "macTodo" {
// s += "SEQUENCE:" + strconv.Itoa(this.Sequence()) + "\n"
// }
// if this.Description() != "" {
// s += "DESCRIPTION:" + encodeTextType(this.Description()) + "\n"
// }
//
// s += "END:VTODO\n"
//
// return s
// }
//
// func encodeDateProperty(name string, t time.Time) string {
// var output string
// zone, _ := t.Zone()
// if zone != "UTC" && zone != "" {
// output = ";TZID=" + zone + ":" + t.Format("20060102T150405")
// } else {
// output = ":" + t.Format("20060102T150405") + "Z"
// }
// return name + output
// }
//
//
// func encodeTextType(s string) string {
// output := ""
// s = escapeTextType(s)
// lineLength := 0
// for _, c := range s {
// if lineLength + len(string(c)) > 75 {
// output += "\n "
// lineLength = 1
// }
// output += string(c)
// lineLength += len(string(c))
// }
// return output
// }

2
vendor/github.com/samedi/caldav-go/.gitignore generated vendored Normal file
View file

@ -0,0 +1,2 @@
test-data/
vendor

69
vendor/github.com/samedi/caldav-go/CHANGELOG.md generated vendored Normal file
View file

@ -0,0 +1,69 @@
# CHANGELOG
v3.0.0
-----------
2017-08-01 Daniel Ferraz <d.ferrazm@gmail.com>
Main change:
Add two ways to get resources from the storage: shallow or not.
`data.GetShallowResource`: means that, if it's a collection resource, it will not include its child VEVENTs in the ICS data.
This is used throughout the palces where children don't matter.
`data.GetResource`: means that the child VEVENTs will be included in the returned ICS content data for collection resources.
This is used when sending a GET request to fetch a specific resource and expecting its full ICS data in response.
Other changes:
* Removed the need to pass the useless `writer http.ResponseWriter` parameter when calling the `caldav.HandleRequest` function.
* Added a `caldav.HandleRequestWithStorage` function that makes it easy to pass a custom storage to be used and handle the request with a single function call.
v2.0.0
-----------
2017-05-10 Daniel Ferraz <d.ferrazm@gmail.com>
All commits squashed and LICENSE updated to release as OSS in github.
Feature-wise it remains the same.
v1.0.1
-----------
2017-01-25 Daniel Ferraz <d.ferrazm@gmail.com>
Escape the contents in `<calendar-data>` and `<displayname>` in the `multistatus` XML responses. Fixing possible bugs
related to having special characters (e.g. &) in the XML multistatus responses that would possibly break the encoding.
v1.0.0
-----------
2017-01-18 Daniel Ferraz <d.ferrazm@gmail.com>
Main feature:
* Handles the `Prefer` header on PROPFIND and REPORT requests (defined in this [draft/proposal](https://tools.ietf.org/html/draft-murchison-webdav-prefer-05)). Useful to shrink down possible big and verbose responses when the client demands. Ex: current iOS calendar client uses this feature on its PROPFIND requests.
Other changes:
* Added the `handlers.Response` to allow clients of the lib to interact with the generated response before being written/sent back to the client.
* Added `GetResourcesByFilters` to the storage interface to allow filtering of resources in the storage level. Useful to provide an already filtered and smaller resource collection to a the REPORT handler when dealing with a filtered REPORT request.
* Added `GetResourcesByList` to the storage interface to fetch a set a of resources based on a set of paths. Useful to provide, in one call, the correct resource collection to the REPORT handler when dealing with a REPORT request for specific `hrefs`.
* Remove useless `IsResourcePresent` from the storage interface.
v0.1.0
-----------
2016-09-23 Daniel Ferraz <d.ferrazm@gmail.com>
This version implements:
* Allow: "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, REPORT"
* DAV: "1, 3, calendar-access"
* Also only handles the following components: `VCALENDAR`, `VEVENT`
Currently unsupported:
* Components `VTODO`, `VJOURNAL`, `VFREEBUSY`
* `VEVENT` recurrences
* Resource locking
* User authentication

20
vendor/github.com/samedi/caldav-go/LICENSE generated vendored Normal file
View file

@ -0,0 +1,20 @@
Copyright 2017 samedi GmbH
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

197
vendor/github.com/samedi/caldav-go/README.md generated vendored Normal file
View file

@ -0,0 +1,197 @@
# go CalDAV
This is a Go lib that aims to implement the CalDAV specification ([RFC4791]). It allows the quick implementation of a CalDAV server in Go. Basically, it provides the request handlers that will handle the several CalDAV HTTP requests, fetch the appropriate resources, build and return the responses.
### How to install
```
go get github.com/samedi/caldav-go
```
### Dependencies
For dependency management, `glide` is used.
```bash
# install glide (once!)
curl https://glide.sh/get | sh
# install dependencies
glide install
```
### How to use it
The easiest way to quickly implement a CalDAV server is by just using the lib's request handler. Example:
```go
package mycaldav
import (
"net/http"
"github.com/samedi/caldav-go"
)
func runServer() {
http.HandleFunc(PATH, caldav.RequestHandler)
http.ListenAndServe(PORT, nil)
}
```
With that, all the HTTP requests (GET, PUT, REPORT, PROPFIND, etc) will be handled and responded by the `caldav` handler. In case of any HTTP methods not supported by the lib, a `501 Not Implemented` response will be returned.
In case you want more flexibility to handle the requests, e.g., if you wanted to access the generated response before being sent back to the caller, you could do like:
```go
package mycaldav
import (
"net/http"
"github.com/samedi/caldav-go"
)
func runServer() {
http.HandleFunc(PATH, myHandler)
http.ListenAndServe(PORT, nil)
}
func myHandler(writer http.ResponseWriter, request *http.Request) {
response := caldav.HandleRequest(request)
// ... do something with the response object before writing it back to the client ...
response.Write(writer)
}
```
### Storage & Resources
The storage is where the CalDAV resources are stored. To interact with that, the caldav lib needs only a type that conforms with the `data.Storage` interface to operate on top of the storage. Basically, this interface defines all the CRUD functions to work on top of the resources. With that, resources can be stored anywhere: in the filesystem, in the cloud, database, etc. As long as the used storage implements all the required storage interface functions, the caldav lib will work fine.
For example, we could use the following dummy read-only storage implementation, which returns dummy hard-coded resources:
```go
type DummyStorage struct{
resources map[string]string{
"/foo": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160914T170000\nEND:VEVENT\nEND:VCALENDAR`,
"/bar": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160915T180000\nEND:VEVENT\nEND:VCALENDAR`,
"/baz": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160916T190000\nEND:VEVENT\nEND:VCALENDAR`,
}
}
func (d *DummyStorage) GetResources(rpath string, withChildren bool) ([]Resource, error) {
return d.GetResourcesByList([]string{rpath})
}
func (d *DummyStorage) GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error) {
return nil, errors.New("filters are not supported")
}
func (d *DummyStorage) GetResourcesByList(rpaths []string) ([]Resource, error) {
result := []Resource{}
for _, rpath := range rpaths {
resource, found, _ := d.GetResource(rpath)
if found {
result = append(result, resource)
}
}
return result, nil
}
func (d *DummyStorage) GetResource(rpath string) (*Resource, bool, error) {
return d.GetShallowResource(rpath)
}
func (d *DummyStorage) GetShallowResource(rpath string) (*Resource, bool, error) {
result := []Resource{}
resContent := d.resources[rpath]
if resContent != "" {
resource = NewResource(rpath, DummyResourceAdapter{rpath, resContent})
return &resource, true, nil
}
return nil, false, nil
}
func (d *DummyStorage) CreateResource(rpath, content string) (*Resource, error) {
return nil, errors.New("creating resources are not supported")
}
func (d *DummyStorage) UpdateResource(rpath, content string) (*Resource, error) {
return nil, errors.New("updating resources are not supported")
}
func (d *DummyStorage) DeleteResource(rpath string) error {
return nil, errors.New("deleting resources are not supported")
}
```
Normally, when you provide your own storage implementation, you will need to provide also a custom `data.ResourceAdapter` interface implementation.
The resource adapter deals with the specificities of how resources are stored, which formats and how to deal with them. For example,
for file resources, the resources contents are the content read from the file itself, for resources in the cloud, it could be in JSON needing
some additional processing to parse the content, etc.
In our example here, we could say that the adapter for this case would be:
```go
type DummyResourceAdapter struct {
resourcePath string
resourceData string
}
func (a *DummyResourceAdapter) IsCollection() bool {
return false
}
func (a *DummyResourceAdapter) GetContent() string {
return a.resourceData
}
func (a *DummyResourceAdapter) GetContentSize() int64 {
return len(a.GetContent())
}
func (a *DummyResourceAdapter) CalculateEtag() string {
return hashify(a.GetContent())
}
func (a *DummyResourceAdapter) GetModTime() time.Time {
return time.Now()
}
```
Note that this adapter implementation is passed over whenever we initialize a new `Resource` instance in the storage implementation.
Then we just need to tell the caldav lib to use our dummy storage:
```go
dummyStg := new(DummyStorage)
caldav.SetupStorage(dummyStg)
```
All the CRUD operations on resources will then be forwarded to our dummy storage.
The default storage used (if none is explicitly set) is the `data.FileStorage` which deals with resources as files in the File System.
The resources can be of two types: collection and non-collection. A collection resource is basically a resource that has children resources, but does not have any data content. A non-collection resource is a resource that does not have children, but has data. In the case of a file storage, collections correspond to directories and non-collection to plain files. The data of a caldav resource is all the info that shows up in the calendar client, in the [iCalendar](https://en.wikipedia.org/wiki/ICalendar) format.
### Features
Please check the **CHANGELOG** to see specific features that are currently implemented.
### Contributing and testing
Everyone is welcome to contribute. Please raise an issue or pull request accordingly.
To run the tests:
```
./test.sh
```
### License
MIT License.
[RFC4791]: https://tools.ietf.org/html/rfc4791

24
vendor/github.com/samedi/caldav-go/config.go generated vendored Normal file
View file

@ -0,0 +1,24 @@
package caldav
import (
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/global"
)
// SetupStorage sets the storage to be used by the server. The storage is where the resources data will be fetched from.
// You can provide a custom storage for your own purposes (which might be looking for data in the cloud, DB, etc).
// Just make sure it implements the `data.Storage` interface.
func SetupStorage(stg data.Storage) {
global.Storage = stg
}
// SetupUser sets the current user which is currently interacting with the calendar.
// It is used, for example, in some of the CALDAV responses, when rendering the path where to find the user's resources.
func SetupUser(username string) {
global.User = &data.CalUser{Name: username}
}
// SetupSupportedComponents sets all components which are supported by this storage implementation.
func SetupSupportedComponents(components []string) {
global.SupportedComponents = components
}

363
vendor/github.com/samedi/caldav-go/data/filters.go generated vendored Normal file
View file

@ -0,0 +1,363 @@
package data
import (
"errors"
"github.com/beevik/etree"
"log"
"strings"
"time"
"github.com/samedi/caldav-go/lib"
)
const (
TAG_FILTER = "filter"
TAG_COMP_FILTER = "comp-filter"
TAG_PROP_FILTER = "prop-filter"
TAG_PARAM_FILTER = "param-filter"
TAG_TIME_RANGE = "time-range"
TAG_TEXT_MATCH = "text-match"
TAG_IS_NOT_DEFINED = "is-not-defined"
// From the RFC, the time range `start` and `end` attributes MUST be in UTC and in this specific format
FILTER_TIME_FORMAT = "20060102T150405Z"
)
// ResourceFilter represents filters to filter out resources.
// Filters are basically a set of rules used to retrieve a range of resources.
// It is used primarily on REPORT requests and is described in details in RFC4791#7.8.
type ResourceFilter struct {
name string
text string
attrs map[string]string
children []ResourceFilter // collection of child filters.
etreeElem *etree.Element // holds the parsed XML node/tag as an `etree` element.
}
// ParseResourceFilters initializes a new `ResourceFilter` object from a snippet of XML string.
func ParseResourceFilters(xml string) (*ResourceFilter, error) {
doc := etree.NewDocument()
if err := doc.ReadFromString(xml); err != nil {
log.Printf("ERROR: Could not parse filter from XML string. XML:\n%s", xml)
return new(ResourceFilter), err
}
// Right now we're searching for a <filter> tag to initialize the filter struct from it.
// It SHOULD be a valid XML CALDAV:filter tag (RFC4791#9.7). We're not checking namespaces yet.
// TODO: check for XML namespaces and restrict it to accept only CALDAV:filter tag.
elem := doc.FindElement("//" + TAG_FILTER)
if elem == nil {
log.Printf("WARNING: The filter XML should contain a <%s> element. XML:\n%s", TAG_FILTER, xml)
return new(ResourceFilter), errors.New("invalid XML filter")
}
filter := newFilterFromEtreeElem(elem)
return &filter, nil
}
func newFilterFromEtreeElem(elem *etree.Element) ResourceFilter {
// init filter from etree element
filter := ResourceFilter{
name: elem.Tag,
text: strings.TrimSpace(elem.Text()),
etreeElem: elem,
attrs: make(map[string]string),
}
// set attributes
for _, attr := range elem.Attr {
filter.attrs[attr.Key] = attr.Value
}
return filter
}
// Attr searches an attribute by its name in the list of filter attributes and returns it.
func (f *ResourceFilter) Attr(attrName string) string {
return f.attrs[attrName]
}
// TimeAttr searches and returns a filter attribute as a `time.Time` object.
func (f *ResourceFilter) TimeAttr(attrName string) *time.Time {
t, err := time.Parse(FILTER_TIME_FORMAT, f.attrs[attrName])
if err != nil {
return nil
}
return &t
}
// GetTimeRangeFilter checks if the current filter has a child "time-range" filter and
// returns it (wrapped in a `ResourceFilter` type). It returns nil if the current filter does
// not contain any "time-range" filter.
func (f *ResourceFilter) GetTimeRangeFilter() *ResourceFilter {
return f.findChild(TAG_TIME_RANGE, true)
}
// Match returns whether a provided resource matches the filters.
func (f *ResourceFilter) Match(target ResourceInterface) bool {
if f.name == TAG_FILTER {
return f.rootFilterMatch(target)
}
return false
}
func (f *ResourceFilter) rootFilterMatch(target ResourceInterface) bool {
if f.isEmpty() {
return false
}
return f.rootChildrenMatch(target)
}
// checks if all the root's child filters match the target resource
func (f *ResourceFilter) rootChildrenMatch(target ResourceInterface) bool {
scope := []string{}
for _, child := range f.getChildren() {
// root filters only accept comp filters as children
if child.name != TAG_COMP_FILTER || !child.compMatch(target, scope) {
return false
}
}
return true
}
// See RFC4791-9.7.1.
func (f *ResourceFilter) compMatch(target ResourceInterface, scope []string) bool {
targetComp := target.ComponentName()
compName := f.attrs["name"]
if f.isEmpty() {
// Point #1 of RFC4791#9.7.1
return compName == targetComp
} else if f.contains(TAG_IS_NOT_DEFINED) {
// Point #2 of RFC4791#9.7.1
return compName != targetComp
} else {
// check each child of the current filter if they all match.
childrenScope := append(scope, compName)
return f.compChildrenMatch(target, childrenScope)
}
}
// checks if all the comp's child filters match the target resource
func (f *ResourceFilter) compChildrenMatch(target ResourceInterface, scope []string) bool {
for _, child := range f.getChildren() {
var match bool
switch child.name {
case TAG_TIME_RANGE:
// Point #3 of RFC4791#9.7.1
match = child.timeRangeMatch(target)
case TAG_PROP_FILTER:
// Point #4 of RFC4791#9.7.1
match = child.propMatch(target, scope)
case TAG_COMP_FILTER:
// Point #4 of RFC4791#9.7.1
match = child.compMatch(target, scope)
}
if !match {
return false
}
}
return true
}
// See RFC4791-9.9
func (f *ResourceFilter) timeRangeMatch(target ResourceInterface) bool {
startAttr := f.attrs["start"]
endAttr := f.attrs["end"]
// at least one of the two MUST be present
if startAttr == "" && endAttr == "" {
// if both of them are missing, return false
return false
} else if startAttr == "" {
// if missing only the `start`, set it open ended to the left
startAttr = "00010101T000000Z"
} else if endAttr == "" {
// if missing only the `end`, set it open ended to the right
endAttr = "99991231T235959Z"
}
// The logic below is only applicable for VEVENT components. So
// we return false if the resource is not a VEVENT component.
if target.ComponentName() != lib.VEVENT {
return false
}
rangeStart, err := time.Parse(FILTER_TIME_FORMAT, startAttr)
if err != nil {
log.Printf("ERROR: Could not parse start time in time-range filter.\nError: %s.\nStart attr: %s", err, startAttr)
return false
}
rangeEnd, err := time.Parse(FILTER_TIME_FORMAT, endAttr)
if err != nil {
log.Printf("ERROR: Could not parse end time in time-range filter.\nError: %s.\nEnd attr: %s", err, endAttr)
return false
}
// the following logic is inferred from the rules table for VEVENT components,
// described in RFC4791-9.9.
overlapRange := func(dtStart, dtEnd, rangeStart, rangeEnd time.Time) bool {
if dtStart.Equal(dtEnd) {
// Lines 3 and 4 of the table deal when the DTSTART and DTEND dates are equals.
// In this case we use the rule: (start <= DTSTART && end > DTSTART)
return (rangeStart.Before(dtStart) || rangeStart.Equal(dtStart)) && rangeEnd.After(dtStart)
} else {
// Lines 1, 2 and 6 of the table deal when the DTSTART and DTEND dates are different.
// In this case we use the rule: (start < DTEND && end > DTSTART)
return rangeStart.Before(dtEnd) && rangeEnd.After(dtStart)
}
}
// first we check each of the target recurrences (if any).
for _, recurrence := range target.Recurrences() {
// if any of them overlap the filter range, we return true right away
if overlapRange(recurrence.StartTime, recurrence.EndTime, rangeStart, rangeEnd) {
return true
}
}
// if none of the recurrences match, we just return if the actual
// resource's `start` and `end` times match the filter range
return overlapRange(target.StartTimeUTC(), target.EndTimeUTC(), rangeStart, rangeEnd)
}
// See RFC4791-9.7.2.
func (f *ResourceFilter) propMatch(target ResourceInterface, scope []string) bool {
propName := f.attrs["name"]
propPath := append(scope, propName)
if f.isEmpty() {
// Point #1 of RFC4791#9.7.2
return target.HasProperty(propPath...)
} else if f.contains(TAG_IS_NOT_DEFINED) {
// Point #2 of RFC4791#9.7.2
return !target.HasProperty(propPath...)
} else {
// check each child of the current filter if they all match.
return f.propChildrenMatch(target, propPath)
}
}
// checks if all the prop's child filters match the target resource
func (f *ResourceFilter) propChildrenMatch(target ResourceInterface, propPath []string) bool {
for _, child := range f.getChildren() {
var match bool
switch child.name {
case TAG_TIME_RANGE:
// Point #3 of RFC4791#9.7.2
// TODO: this point is not very clear on how to match time range against properties.
// So we're returning `false` in the meantime.
match = false
case TAG_TEXT_MATCH:
// Point #4 of RFC4791#9.7.2
propText := target.GetPropertyValue(propPath...)
match = child.textMatch(propText)
case TAG_PARAM_FILTER:
// Point #4 of RFC4791#9.7.2
match = child.paramMatch(target, propPath)
}
if !match {
return false
}
}
return true
}
// See RFC4791-9.7.3
func (f *ResourceFilter) paramMatch(target ResourceInterface, parentPropPath []string) bool {
paramName := f.attrs["name"]
paramPath := append(parentPropPath, paramName)
if f.isEmpty() {
// Point #1 of RFC4791#9.7.3
return target.HasPropertyParam(paramPath...)
} else if f.contains(TAG_IS_NOT_DEFINED) {
// Point #2 of RFC4791#9.7.3
return !target.HasPropertyParam(paramPath...)
} else {
child := f.getChildren()[0]
// param filters can also have (only-one) nested text-match filter
if child.name == TAG_TEXT_MATCH {
paramValue := target.GetPropertyParamValue(paramPath...)
return child.textMatch(paramValue)
}
}
return false
}
// See RFC4791-9.7.5
func (f *ResourceFilter) textMatch(targetText string) bool {
// TODO: collations are not being considered/supported yet.
// Texts are lowered to be case-insensitive, almost as the "i;ascii-casemap" value.
targetText = strings.ToLower(targetText)
expectedSubstr := strings.ToLower(f.text)
match := strings.Contains(targetText, expectedSubstr)
if f.attrs["negate-condition"] == "yes" {
return !match
}
return match
}
func (f *ResourceFilter) isEmpty() bool {
return len(f.getChildren()) == 0 && f.text == ""
}
func (f *ResourceFilter) contains(filterName string) bool {
if f.findChild(filterName, false) != nil {
return true
}
return false
}
func (f *ResourceFilter) findChild(filterName string, dig bool) *ResourceFilter {
for _, child := range f.getChildren() {
if child.name == filterName {
return &child
}
if !dig {
continue
}
dugChild := child.findChild(filterName, true)
if dugChild != nil {
return dugChild
}
}
return nil
}
// lazy evaluation of the child filters
func (f *ResourceFilter) getChildren() []ResourceFilter {
if f.children == nil {
f.children = []ResourceFilter{}
for _, childElem := range f.etreeElem.ChildElements() {
childFilter := newFilterFromEtreeElem(childElem)
f.children = append(f.children, childFilter)
}
}
return f.children
}

370
vendor/github.com/samedi/caldav-go/data/resource.go generated vendored Normal file
View file

@ -0,0 +1,370 @@
package data
import (
"fmt"
"github.com/laurent22/ical-go/ical"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/samedi/caldav-go/files"
"github.com/samedi/caldav-go/lib"
)
// ResourceInterface defines the main interface of a CalDAV resource object. This
// interface exists only to define the common resource operation and should not be custom-implemented.
// The default and canonical implementation is provided by `data.Resource`, convering all the commonalities.
// Any specifics in implementations should be handled by the `data.ResourceAdapter`.
type ResourceInterface interface {
ComponentName() string
StartTimeUTC() time.Time
EndTimeUTC() time.Time
Recurrences() []ResourceRecurrence
HasProperty(propPath ...string) bool
GetPropertyValue(propPath ...string) string
HasPropertyParam(paramName ...string) bool
GetPropertyParamValue(paramName ...string) string
}
// ResourceAdapter serves as the object to abstract all the specicities in different resources implementations.
// For example, the way to tell whether a resource is a collection or how to read its content differentiates
// on resources stored in the file system, coming from a relational DB or from the cloud as JSON. These differentiations
// should be covered by providing a specific implementation of the `ResourceAdapter` interface. So, depending on the current
// resource storage strategy, a matching resource adapter implementation should be provided whenever a new resource is initialized.
type ResourceAdapter interface {
IsCollection() bool
CalculateEtag() string
GetContent() string
GetContentSize() int64
GetModTime() time.Time
}
// ResourceRecurrence represents a recurrence for a resource.
// NOTE: recurrences are not supported yet.
type ResourceRecurrence struct {
StartTime time.Time
EndTime time.Time
}
// Resource represents the CalDAV resource. Basically, it has a name it's accessible based on path.
// A resource can be a collection, meaning it doesn't have any data content, but it has child resources.
// A non-collection is the actual resource which has the data in iCal format and which will feed the calendar.
// When visualizing the whole resources set in a tree representation, the collection resource would be the inner nodes and
// the non-collection would be the leaves.
type Resource struct {
Name string
Path string
pathSplit []string
adapter ResourceAdapter
emptyTime time.Time
}
// NewResource initializes a new `Resource` instance based on its path and the `ResourceAdapter` implementation to be used.
func NewResource(rawPath string, adp ResourceAdapter) Resource {
pClean := lib.ToSlashPath(rawPath)
pSplit := strings.Split(strings.Trim(pClean, "/"), "/")
return Resource{
Name: pSplit[len(pSplit)-1],
Path: pClean,
pathSplit: pSplit,
adapter: adp,
}
}
// IsCollection tells whether a resource is a collection or not.
func (r *Resource) IsCollection() bool {
return r.adapter.IsCollection()
}
// IsPrincipal tells whether a resource is the principal resource or not.
// A principal resource means it's a root resource.
func (r *Resource) IsPrincipal() bool {
return len(r.pathSplit) <= 1
}
// ComponentName returns the type of the resource. VCALENDAR for collection resources, VEVENT otherwise.
func (r *Resource) ComponentName() string {
if r.IsCollection() {
return lib.VCALENDAR
}
return lib.VEVENT
}
// StartTimeUTC returns the start time in UTC of a VEVENT resource.
func (r *Resource) StartTimeUTC() time.Time {
vevent := r.icalVEVENT()
dtstart := vevent.PropDate(ical.DTSTART, r.emptyTime)
if dtstart == r.emptyTime {
log.Printf("WARNING: The property DTSTART was not found in the resource's ical data.\nResource path: %s", r.Path)
return r.emptyTime
}
return dtstart.UTC()
}
// EndTimeUTC returns the end time in UTC of a VEVENT resource.
func (r *Resource) EndTimeUTC() time.Time {
vevent := r.icalVEVENT()
dtend := vevent.PropDate(ical.DTEND, r.emptyTime)
// when the DTEND property is not present, we just add the DURATION (if any) to the DTSTART
if dtend == r.emptyTime {
duration := vevent.PropDuration(ical.DURATION)
dtend = r.StartTimeUTC().Add(duration)
}
return dtend.UTC()
}
// Recurrences returns an array of resource recurrences.
// NOTE: Recurrences are not supported yet. An empty array will always be returned.
func (r *Resource) Recurrences() []ResourceRecurrence {
// TODO: Implement. This server does not support iCal recurrences yet. We just return an empty array.
return []ResourceRecurrence{}
}
// HasProperty tells whether the resource has the provided property in its iCal content.
// The path to the property should be provided in case of nested properties.
// Example, suppose the resource has this content:
//
// BEGIN:VCALENDAR
// BEGIN:VEVENT
// DTSTART:20160914T170000
// END:VEVENT
// END:VCALENDAR
//
// HasProperty("VEVENT", "DTSTART") => returns true
// HasProperty("VEVENT", "DTEND") => returns false
func (r *Resource) HasProperty(propPath ...string) bool {
return r.GetPropertyValue(propPath...) != ""
}
// GetPropertyValue gets a property value from the resource's iCal content.
// The path to the property should be provided in case of nested properties.
// Example, suppose the resource has this content:
//
// BEGIN:VCALENDAR
// BEGIN:VEVENT
// DTSTART:20160914T170000
// END:VEVENT
// END:VCALENDAR
//
// GetPropertyValue("VEVENT", "DTSTART") => returns "20160914T170000"
// GetPropertyValue("VEVENT", "DTEND") => returns ""
func (r *Resource) GetPropertyValue(propPath ...string) string {
if propPath[0] == ical.VCALENDAR {
propPath = propPath[1:]
}
prop, _ := r.icalendar().DigProperty(propPath...)
return prop
}
// HasPropertyParam tells whether the resource has the provided property param in its iCal content.
// The path to the param should be provided in case of nested params.
// Example, suppose the resource has this content:
//
// BEGIN:VCALENDAR
// BEGIN:VEVENT
// ATTENDEE;PARTSTAT=NEEDS-ACTION:FOO
// END:VEVENT
// END:VCALENDAR
//
// HasPropertyParam("VEVENT", "ATTENDEE", "PARTSTAT") => returns true
// HasPropertyParam("VEVENT", "ATTENDEE", "OTHER") => returns false
func (r *Resource) HasPropertyParam(paramPath ...string) bool {
return r.GetPropertyParamValue(paramPath...) != ""
}
// GetPropertyParamValue gets a property param value from the resource's iCal content.
// The path to the param should be provided in case of nested params.
// Example, suppose the resource has this content:
//
// BEGIN:VCALENDAR
// BEGIN:VEVENT
// ATTENDEE;PARTSTAT=NEEDS-ACTION:FOO
// END:VEVENT
// END:VCALENDAR
//
// GetPropertyParamValue("VEVENT", "ATTENDEE", "PARTSTAT") => returns "NEEDS-ACTION"
// GetPropertyParamValue("VEVENT", "ATTENDEE", "OTHER") => returns ""
func (r *Resource) GetPropertyParamValue(paramPath ...string) string {
if paramPath[0] == ical.VCALENDAR {
paramPath = paramPath[1:]
}
param, _ := r.icalendar().DigParameter(paramPath...)
return param
}
// GetEtag returns the ETag of the resource and a flag saying if the ETag is present.
// For collection resource, it returns an empty string and false.
func (r *Resource) GetEtag() (string, bool) {
if r.IsCollection() {
return "", false
}
return r.adapter.CalculateEtag(), true
}
// GetContentType returns the type of the content of the resource.
// Collection resources are "text/calendar". Non-collection resources are "text/calendar; component=vcalendar".
func (r *Resource) GetContentType() (string, bool) {
if r.IsCollection() {
return "text/calendar", true
}
return "text/calendar; component=vcalendar", true
}
// GetDisplayName returns the name/identifier of the resource.
func (r *Resource) GetDisplayName() (string, bool) {
return r.Name, true
}
// GetContentData reads and returns the raw content of the resource as string and flag saying if the content was found.
// If the resource does not have content (like collection resource), it returns an empty string and false.
func (r *Resource) GetContentData() (string, bool) {
data := r.adapter.GetContent()
found := data != ""
return data, found
}
// GetContentLength returns the length of the resource's content and flag saying if the length is present.
// If the resource does not have content (like collection resource), it returns an empty string and false.
func (r *Resource) GetContentLength() (string, bool) {
// If its collection, it does not have any content, so mark it as not found
if r.IsCollection() {
return "", false
}
contentSize := r.adapter.GetContentSize()
return strconv.FormatInt(contentSize, 10), true
}
// GetLastModified returns the last time the resource was modified. The returned time
// is returned formatted in the provided `format`.
func (r *Resource) GetLastModified(format string) (string, bool) {
return r.adapter.GetModTime().Format(format), true
}
// GetOwner returns the owner of the resource. This is usually the principal resource associated (the root resource).
// If the resource does not have a owner (for example it's a principal resource alread), it returns an empty string.
func (r *Resource) GetOwner() (string, bool) {
var owner string
if len(r.pathSplit) > 1 {
owner = r.pathSplit[0]
} else {
owner = ""
}
return owner, true
}
// GetOwnerPath returns the path to this resource's owner, or an empty string when the resource does not have any owner.
func (r *Resource) GetOwnerPath() (string, bool) {
owner, _ := r.GetOwner()
if owner != "" {
return fmt.Sprintf("/%s/", owner), true
}
return "", false
}
// TODO: memoize
func (r *Resource) icalVEVENT() *ical.Node {
vevent := r.icalendar().ChildByName(ical.VEVENT)
// if nil, log it and return an empty vevent
if vevent == nil {
log.Printf("WARNING: The resource's ical data is missing the VEVENT property.\nResource path: %s", r.Path)
return &ical.Node{
Name: ical.VEVENT,
}
}
return vevent
}
// TODO: memoize
func (r *Resource) icalendar() *ical.Node {
data, found := r.GetContentData()
if !found {
log.Printf("WARNING: The resource's ical data does not have any data.\nResource path: %s", r.Path)
return &ical.Node{
Name: ical.VCALENDAR,
}
}
icalNode, err := ical.ParseCalendar(data)
if err != nil {
log.Printf("ERROR: Could not parse the resource's ical data.\nError: %s.\nResource path: %s", err, r.Path)
return &ical.Node{
Name: ical.VCALENDAR,
}
}
return icalNode
}
// FileResourceAdapter implements the `ResourceAdapter` for resources stored as files in the file system.
type FileResourceAdapter struct {
finfo os.FileInfo
resourcePath string
}
// IsCollection tells whether the file resource is a directory or not.
func (adp *FileResourceAdapter) IsCollection() bool {
return adp.finfo.IsDir()
}
// GetContent reads the file content and returns it as string. For collection resources (directories), it
// returns an empty string.
func (adp *FileResourceAdapter) GetContent() string {
if adp.IsCollection() {
return ""
}
data, err := ioutil.ReadFile(files.AbsPath(adp.resourcePath))
if err != nil {
log.Printf("ERROR: Could not read file content for the resource.\nError: %s.\nResource path: %s.", err, adp.resourcePath)
return ""
}
return string(data)
}
// GetContentSize returns the content length.
func (adp *FileResourceAdapter) GetContentSize() int64 {
return adp.finfo.Size()
}
// CalculateEtag calculates an ETag based on the file current modification status and returns it.
func (adp *FileResourceAdapter) CalculateEtag() string {
// returns ETag as the concatenated hex values of a file's
// modification time and size. This is not a reliable synchronization
// mechanism for directories, so for collections we return empty.
if adp.IsCollection() {
return ""
}
fi := adp.finfo
return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size())
}
// GetModTime returns the time when the file was last modified.
func (adp *FileResourceAdapter) GetModTime() time.Time {
return adp.finfo.ModTime()
}

229
vendor/github.com/samedi/caldav-go/data/storage.go generated vendored Normal file
View file

@ -0,0 +1,229 @@
package data
import (
"github.com/samedi/caldav-go/errs"
"github.com/samedi/caldav-go/files"
"io/ioutil"
"log"
"os"
)
// Storage is the inteface responsible for the CRUD operations on the CalDAV resources. It represents
// where the resources should be fetched from and the various operations which can be performed on it.
// This is the interface one should implement in case it needs a custom storage strategy, like fetching
// data from the cloud, local DB, etc. After that, the custom storage implementation can be setup to be used
// in the server by passing the object instance to `caldav.SetupStorage`.
type Storage interface {
// GetResources gets a list of resources based on a given `rpath`. The
// `rpath` is the path to the original resource that's being requested. The resultant list
// will/must contain that original resource in it, apart from any additional resources. It also receives
// `withChildren` flag to say if the result must also include all the original resource`s
// children (if original is a collection resource). If `true`, the result will have the requested resource + children.
// If `false`, it will have only the requested original resource (from the `rpath` path).
// It returns errors if anything went wrong or if it could not find any resource on `rpath` path.
GetResources(rpath string, withChildren bool) ([]Resource, error)
// GetResourcesByList fetches a list of resources by path from the storage.
// This method fetches all the `rpaths` and return an array of the reosurces found.
// No error 404 will be returned if one of the resources cannot be found.
// Errors are returned if any errors other than "not found" happens.
GetResourcesByList(rpaths []string) ([]Resource, error)
// GetResourcesByFilters returns the filtered children of a target collection resource.
// The target collection resource is the one pointed by the `rpath` parameter. All of its children
// will be checked against a set of `filters` and the matching ones are returned. The results
// contains only the filtered children and does NOT include the target resource. If the target resource
// is not a collection, an empty array is returned as the result.
GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error)
// GetResource gets the requested resource based on a given `rpath` path. It returns the resource (if found) or
// nil (if not found). Also returns a flag specifying if the resource was found or not.
GetResource(rpath string) (*Resource, bool, error)
// GetShallowResource has the same behaviour of `storage.GetResource`. The only difference is that, for collection resources,
// it does not return its children in the collection `storage.Resource` struct (hence the name shallow). The motive is
// for optimizations reasons, as this function is used on places where the collection's children are not important.
GetShallowResource(rpath string) (*Resource, bool, error)
// CreateResource creates a new resource on the `rpath` path with a given `content`.
CreateResource(rpath, content string) (*Resource, error)
// UpdateResource udpates a resource on the `rpath` path with a given `content`.
UpdateResource(rpath, content string) (*Resource, error)
// DeleteResource deletes a resource on the `rpath` path.
DeleteResource(rpath string) error
}
// FileStorage is the storage that deals with resources as files in the file system. So, a collection resource
// is treated as a folder/directory and its children resources are the files it contains. Non-collection resources are just plain files.
// Each file represents then a CalAV resource and the data expects to contain the iCal data to feed the calendar events.
type FileStorage struct {
}
// GetResources get the file resources based on the `rpath`. See `Storage.GetResources` doc.
func (fs *FileStorage) GetResources(rpath string, withChildren bool) ([]Resource, error) {
result := []Resource{}
// tries to open the file by the given path
f, e := fs.openResourceFile(rpath, os.O_RDONLY)
if e != nil {
return nil, e
}
// add it as a resource to the result list
finfo, _ := f.Stat()
resource := NewResource(rpath, &FileResourceAdapter{finfo, rpath})
result = append(result, resource)
// if the file is a dir, add its children to the result list
if withChildren && finfo.IsDir() {
dirFiles, _ := f.Readdir(0)
for _, finfo := range dirFiles {
childPath := files.JoinPaths(rpath, finfo.Name())
resource = NewResource(childPath, &FileResourceAdapter{finfo, childPath})
result = append(result, resource)
}
}
return result, nil
}
// GetResourcesByFilters get the file resources based on the `rpath` and a set of filters. See `Storage.GetResourcesByFilters` doc.
func (fs *FileStorage) GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error) {
result := []Resource{}
childPaths := fs.getDirectoryChildPaths(rpath)
for _, path := range childPaths {
resource, _, err := fs.GetShallowResource(path)
if err != nil {
// if we can't find this resource, something weird went wrong, but not that serious, so we log it and continue
log.Printf("WARNING: returned error when trying to get resource with path %s from collection with path %s. Error: %s", path, rpath, err)
continue
}
// only add it if the resource matches the filters
if filters == nil || filters.Match(resource) {
result = append(result, *resource)
}
}
return result, nil
}
// GetResourcesByList get a list of file resources based on a list of `rpaths`. See `Storage.GetResourcesByList` doc.
func (fs *FileStorage) GetResourcesByList(rpaths []string) ([]Resource, error) {
results := []Resource{}
for _, rpath := range rpaths {
resource, found, err := fs.GetShallowResource(rpath)
if err != nil && err != errs.ResourceNotFoundError {
return nil, err
}
if found {
results = append(results, *resource)
}
}
return results, nil
}
// GetResource fetches and returns a single resource for a `rpath`. See `Storage.GetResoure` doc.
func (fs *FileStorage) GetResource(rpath string) (*Resource, bool, error) {
// For simplicity we just return the shallow resource.
return fs.GetShallowResource(rpath)
}
// GetShallowResource fetches and returns a single resource file/directory without any related children. See `Storage.GetShallowResource` doc.
func (fs *FileStorage) GetShallowResource(rpath string) (*Resource, bool, error) {
resources, err := fs.GetResources(rpath, false)
if err != nil {
return nil, false, err
}
if resources == nil || len(resources) == 0 {
return nil, false, errs.ResourceNotFoundError
}
res := resources[0]
return &res, true, nil
}
// CreateResource creates a file resource with the provided `content`. See `Storage.CreateResource` doc.
func (fs *FileStorage) CreateResource(rpath, content string) (*Resource, error) {
rAbsPath := files.AbsPath(rpath)
if fs.isResourcePresent(rAbsPath) {
return nil, errs.ResourceAlreadyExistsError
}
// create parent directories (if needed)
if err := os.MkdirAll(files.DirPath(rAbsPath), os.ModePerm); err != nil {
return nil, err
}
// create file/resource and write content
f, err := os.Create(rAbsPath)
if err != nil {
return nil, err
}
f.WriteString(content)
finfo, _ := f.Stat()
res := NewResource(rpath, &FileResourceAdapter{finfo, rpath})
return &res, nil
}
// UpdateResource updates a file resource with the provided `content`. See `Storage.UpdateResource` doc.
func (fs *FileStorage) UpdateResource(rpath, content string) (*Resource, error) {
f, e := fs.openResourceFile(rpath, os.O_RDWR)
if e != nil {
return nil, e
}
// update content
f.Truncate(0)
f.WriteString(content)
finfo, _ := f.Stat()
res := NewResource(rpath, &FileResourceAdapter{finfo, rpath})
return &res, nil
}
// DeleteResource deletes a file resource (and possibly all its children in case of a collection). See `Storage.DeleteResource` doc.
func (fs *FileStorage) DeleteResource(rpath string) error {
err := os.Remove(files.AbsPath(rpath))
return err
}
func (fs *FileStorage) isResourcePresent(rpath string) bool {
_, found, _ := fs.GetShallowResource(rpath)
return found
}
func (fs *FileStorage) openResourceFile(filepath string, mode int) (*os.File, error) {
f, e := os.OpenFile(files.AbsPath(filepath), mode, 0666)
if e != nil {
if os.IsNotExist(e) {
return nil, errs.ResourceNotFoundError
}
return nil, e
}
return f, nil
}
func (fs *FileStorage) getDirectoryChildPaths(dirpath string) []string {
content, err := ioutil.ReadDir(files.AbsPath(dirpath))
if err != nil {
log.Printf("ERROR: Could not read resource as file directory.\nError: %s.\nResource path: %s.", err, dirpath)
return nil
}
result := []string{}
for _, file := range content {
fpath := files.JoinPaths(dirpath, file.Name())
result = append(result, fpath)
}
return result
}

8
vendor/github.com/samedi/caldav-go/data/user.go generated vendored Normal file
View file

@ -0,0 +1,8 @@
package data
// CalUser represents the calendar user. It is used, for example, to
// keep track globally what is the current user interacting with the calendar.
// This user data can be used in various places, including in some of the CALDAV responses.
type CalUser struct {
Name string
}

12
vendor/github.com/samedi/caldav-go/errs/errors.go generated vendored Normal file
View file

@ -0,0 +1,12 @@
package errs
import (
"errors"
)
var (
ResourceNotFoundError = errors.New("caldav: resource not found")
ResourceAlreadyExistsError = errors.New("caldav: resource already exists")
UnauthorizedError = errors.New("caldav: unauthorized. credentials needed.")
ForbiddenError = errors.New("caldav: forbidden operation.")
)

34
vendor/github.com/samedi/caldav-go/files/paths.go generated vendored Normal file
View file

@ -0,0 +1,34 @@
package files
import (
"github.com/samedi/caldav-go/lib"
"path/filepath"
"strings"
)
const (
Separator = string(filepath.Separator)
)
// AbsPath converts the path into absolute path based on the current working directory.
func AbsPath(path string) string {
path = strings.Trim(path, "/")
absPath, _ := filepath.Abs(path)
return absPath
}
// DirPath returns all but the last element of path, typically the path's directory.
func DirPath(path string) string {
return filepath.Dir(path)
}
// JoinPaths joins two or more paths into a single path.
func JoinPaths(paths ...string) string {
return filepath.Join(paths...)
}
// ToSlashPath slashify the path, using '/' as separator.
func ToSlashPath(path string) string {
return lib.ToSlashPath(path)
}

8
vendor/github.com/samedi/caldav-go/glide.lock generated vendored Normal file
View file

@ -0,0 +1,8 @@
hash: ff037054c7b689c20a4fd4368897b8998d6ffa31b0f3f4a79e523a132e6a9e94
updated: 2018-04-04T08:38:45.523594-04:00
imports:
- name: github.com/beevik/etree
version: af219c0c7ea1b67ec263c0b1b1b96d284a9181ce
- name: github.com/laurent22/ical-go
version: e4fec34929693e2a4ba299d16380c55bac3fb42c
testImports: []

4
vendor/github.com/samedi/caldav-go/glide.yaml generated vendored Normal file
View file

@ -0,0 +1,4 @@
package: github.com/samedi/caldav-go
import:
- package: github.com/beevik/etree
- package: github.com/laurent22/ical-go

17
vendor/github.com/samedi/caldav-go/global/global.go generated vendored Normal file
View file

@ -0,0 +1,17 @@
// Package global defines the globally accessible variables in the caldav server
// and the interface to setup them.
package global
import (
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/lib"
)
// Storage represents the global storage used in the CRUD operations of resources. Default storage is the `data.FileStorage`.
var Storage data.Storage = new(data.FileStorage)
// User defines the current caldav user, which is the user currently interacting with the calendar.
var User *data.CalUser
// SupportedComponents contains all components which are supported by the current storage implementation
var SupportedComponents = []string{lib.VCALENDAR, lib.VEVENT}

8
vendor/github.com/samedi/caldav-go/go.mod generated vendored Normal file
View file

@ -0,0 +1,8 @@
module github.com/samedi/caldav-go
go 1.12
require (
github.com/beevik/etree v0.0.0-20171015221209-af219c0c7ea1
github.com/laurent22/ical-go v0.0.0-20170824131750-e4fec3492969
)

29
vendor/github.com/samedi/caldav-go/handler.go generated vendored Normal file
View file

@ -0,0 +1,29 @@
package caldav
import (
"net/http"
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/handlers"
)
// RequestHandler handles the given CALDAV request and writes the reponse righ away. This function is to be
// used by passing it directly as the handle func to the `http` lib. Example: http.HandleFunc("/", caldav.RequestHandler).
func RequestHandler(writer http.ResponseWriter, request *http.Request) {
response := HandleRequest(request)
response.Write(writer)
}
// HandleRequest handles the given CALDAV request and returns the response. Useful when the caller
// wants to do something else with the response before writing it to the response stream.
func HandleRequest(request *http.Request) *handlers.Response {
handler := handlers.NewHandler(request)
return handler.Handle()
}
// HandleRequestWithStorage handles the request the same way as `HandleRequest` does, but before,
// it sets the given storage that will be used throughout the request handling flow.
func HandleRequestWithStorage(request *http.Request, stg data.Storage) *handlers.Response {
SetupStorage(stg)
return HandleRequest(request)
}

36
vendor/github.com/samedi/caldav-go/handlers/builder.go generated vendored Normal file
View file

@ -0,0 +1,36 @@
package handlers
import (
"net/http"
)
// HandlerInterface represents a CalDAV request handler. It has only one function `Handle`,
// which is used to handle the CalDAV request and returns the response.
type HandlerInterface interface {
Handle() *Response
}
// NewHandler returns a new CalDAV request handler object based on the provided request.
// With the returned request handler, you can call `Handle()` to handle the request.
func NewHandler(request *http.Request) HandlerInterface {
response := NewResponse()
switch request.Method {
case "GET":
return getHandler{request, response, false}
case "HEAD":
return getHandler{request, response, true}
case "PUT":
return putHandler{request, response}
case "DELETE":
return deleteHandler{request, response}
case "PROPFIND":
return propfindHandler{request, response}
case "OPTIONS":
return optionsHandler{response}
case "REPORT":
return reportHandler{request, response}
default:
return notImplementedHandler{response}
}
}

40
vendor/github.com/samedi/caldav-go/handlers/delete.go generated vendored Normal file
View file

@ -0,0 +1,40 @@
package handlers
import (
"github.com/samedi/caldav-go/global"
"net/http"
)
type deleteHandler struct {
request *http.Request
response *Response
}
func (dh deleteHandler) Handle() *Response {
precond := requestPreconditions{dh.request}
// get the event from the storage
resource, _, err := global.Storage.GetShallowResource(dh.request.URL.Path)
if err != nil {
return dh.response.SetError(err)
}
// TODO: Handle delete on collections
if resource.IsCollection() {
return dh.response.Set(http.StatusMethodNotAllowed, "")
}
// check ETag pre-condition
resourceEtag, _ := resource.GetEtag()
if !precond.IfMatch(resourceEtag) {
return dh.response.Set(http.StatusPreconditionFailed, "")
}
// delete event after pre-condition passed
err = global.Storage.DeleteResource(resource.Path)
if err != nil {
return dh.response.SetError(err)
}
return dh.response.Set(http.StatusNoContent, "")
}

37
vendor/github.com/samedi/caldav-go/handlers/get.go generated vendored Normal file
View file

@ -0,0 +1,37 @@
package handlers
import (
"github.com/samedi/caldav-go/global"
"net/http"
)
type getHandler struct {
request *http.Request
response *Response
onlyHeaders bool
}
func (gh getHandler) Handle() *Response {
resource, _, err := global.Storage.GetResource(gh.request.URL.Path)
if err != nil {
return gh.response.SetError(err)
}
var response string
if gh.onlyHeaders {
response = ""
} else {
response, _ = resource.GetContentData()
}
etag, _ := resource.GetEtag()
lastm, _ := resource.GetLastModified(http.TimeFormat)
ctype, _ := resource.GetContentType()
gh.response.SetHeader("ETag", etag).
SetHeader("Last-Modified", lastm).
SetHeader("Content-Type", ctype).
Set(http.StatusOK, response)
return gh.response
}

27
vendor/github.com/samedi/caldav-go/handlers/headers.go generated vendored Normal file
View file

@ -0,0 +1,27 @@
package handlers
import (
"net/http"
)
const (
HD_DEPTH = "Depth"
HD_DEPTH_DEEP = "1"
HD_PREFER = "Prefer"
HD_PREFER_MINIMAL = "return=minimal"
HD_PREFERENCE_APPLIED = "Preference-Applied"
)
type headers struct {
http.Header
}
func (h headers) IsDeep() bool {
depth := h.Get(HD_DEPTH)
return (depth == HD_DEPTH_DEEP)
}
func (h headers) IsMinimal() bool {
prefer := h.Get(HD_PREFER)
return (prefer == HD_PREFER_MINIMAL)
}

View file

@ -0,0 +1,207 @@
package handlers
import (
"encoding/xml"
"fmt"
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/global"
"github.com/samedi/caldav-go/ixml"
"github.com/samedi/caldav-go/lib"
"net/http"
)
// Wraps a multistatus response. It contains the set of `Responses`
// that will serve to build the final XML. Multistatus responses are
// used by the REPORT and PROPFIND methods.
type multistatusResp struct {
// The set of multistatus responses used to build each of the <DAV:response> nodes.
Responses []msResponse
// Flag that XML should be minimal or not
// [defined in the draft https://tools.ietf.org/html/draft-murchison-webdav-prefer-05]
Minimal bool
}
type msResponse struct {
Href string
Found bool
Propstats msPropstats
}
type msPropstats map[int]msProps
// Adds a msProp to the map with the key being the prop status.
func (stats msPropstats) Add(prop msProp) {
stats[prop.Status] = append(stats[prop.Status], prop)
}
func (stats msPropstats) Clone() msPropstats {
clone := make(msPropstats)
for k, v := range stats {
clone[k] = v
}
return clone
}
type msProps []msProp
type msProp struct {
Tag xml.Name
Content string
Contents []string
Status int
}
// Function that processes all the required props for a given resource.
// ## Params
// resource: the target calendar resource.
// reqprops: set of required props that must be processed for the resource.
// ## Returns
// The set of props (msProp) processed. Each prop is mapped to a HTTP status code.
// So if a prop is found and processed ok, it'll be mapped to 200. If it's not found,
// it'll be mapped to 404, and so on.
func (ms *multistatusResp) Propstats(resource *data.Resource, reqprops []xml.Name) msPropstats {
if resource == nil {
return nil
}
result := make(msPropstats)
for _, ptag := range reqprops {
pvalue := msProp{
Tag: ptag,
Status: http.StatusOK,
}
pfound := false
switch ptag {
case ixml.CALENDAR_DATA_TG:
pvalue.Content, pfound = resource.GetContentData()
if pfound {
pvalue.Content = ixml.EscapeText(pvalue.Content)
}
case ixml.GET_ETAG_TG:
pvalue.Content, pfound = resource.GetEtag()
case ixml.GET_CONTENT_TYPE_TG:
pvalue.Content, pfound = resource.GetContentType()
case ixml.GET_CONTENT_LENGTH_TG:
pvalue.Content, pfound = resource.GetContentLength()
case ixml.DISPLAY_NAME_TG:
pvalue.Content, pfound = resource.GetDisplayName()
if pfound {
pvalue.Content = ixml.EscapeText(pvalue.Content)
}
case ixml.GET_LAST_MODIFIED_TG:
pvalue.Content, pfound = resource.GetLastModified(http.TimeFormat)
case ixml.OWNER_TG:
pvalue.Content, pfound = resource.GetOwnerPath()
case ixml.GET_CTAG_TG:
pvalue.Content, pfound = resource.GetEtag()
case ixml.PRINCIPAL_URL_TG,
ixml.PRINCIPAL_COLLECTION_SET_TG,
ixml.CALENDAR_USER_ADDRESS_SET_TG,
ixml.CALENDAR_HOME_SET_TG:
pvalue.Content, pfound = ixml.HrefTag(resource.Path), true
case ixml.RESOURCE_TYPE_TG:
if resource.IsCollection() {
pvalue.Content, pfound = ixml.Tag(ixml.COLLECTION_TG, "")+ixml.Tag(ixml.CALENDAR_TG, ""), true
if resource.IsPrincipal() {
pvalue.Content += ixml.Tag(ixml.PRINCIPAL_TG, "")
}
} else {
// resourcetype must be returned empty for non-collection elements
pvalue.Content, pfound = "", true
}
case ixml.CURRENT_USER_PRINCIPAL_TG:
if global.User != nil {
path := fmt.Sprintf("/%s/", global.User.Name)
pvalue.Content, pfound = ixml.HrefTag(path), true
}
case ixml.SUPPORTED_CALENDAR_COMPONENT_SET_TG:
if resource.IsCollection() {
for _, component := range global.SupportedComponents {
// TODO: use ixml somehow to build the below tag
compTag := fmt.Sprintf(`<C:comp name="%s"/>`, component)
pvalue.Contents = append(pvalue.Contents, compTag)
}
pfound = true
}
}
if !pfound {
pvalue.Status = http.StatusNotFound
}
result.Add(pvalue)
}
return result
}
// Adds a new `msResponse` to the `Responses` array.
func (ms *multistatusResp) AddResponse(href string, found bool, propstats msPropstats) {
ms.Responses = append(ms.Responses, msResponse{
Href: href,
Found: found,
Propstats: propstats,
})
}
func (ms *multistatusResp) ToXML() string {
// init multistatus
var bf lib.StringBuffer
bf.Write(`<?xml version="1.0" encoding="UTF-8"?>`)
bf.Write(`<D:multistatus %s>`, ixml.Namespaces())
// iterate over event hrefs and build multistatus XML on the fly
for _, response := range ms.Responses {
bf.Write("<D:response>")
bf.Write(ixml.HrefTag(response.Href))
if response.Found {
propstats := response.Propstats.Clone()
if ms.Minimal {
delete(propstats, http.StatusNotFound)
if len(propstats) == 0 {
bf.Write("<D:propstat>")
bf.Write("<D:prop/>")
bf.Write(ixml.StatusTag(http.StatusOK))
bf.Write("</D:propstat>")
bf.Write("</D:response>")
continue
}
}
for status, props := range propstats {
bf.Write("<D:propstat>")
bf.Write("<D:prop>")
for _, prop := range props {
bf.Write(ms.propToXML(prop))
}
bf.Write("</D:prop>")
bf.Write(ixml.StatusTag(status))
bf.Write("</D:propstat>")
}
} else {
// if does not find the resource set 404
bf.Write(ixml.StatusTag(http.StatusNotFound))
}
bf.Write("</D:response>")
}
bf.Write("</D:multistatus>")
return bf.String()
}
func (ms *multistatusResp) propToXML(prop msProp) string {
for _, content := range prop.Contents {
prop.Content += content
}
xmlString := ixml.Tag(prop.Tag, prop.Content)
return xmlString
}

View file

@ -0,0 +1,13 @@
package handlers
import (
"net/http"
)
type notImplementedHandler struct {
response *Response
}
func (h notImplementedHandler) Handle() *Response {
return h.response.Set(http.StatusNotImplemented, "")
}

23
vendor/github.com/samedi/caldav-go/handlers/options.go generated vendored Normal file
View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
)
type optionsHandler struct {
response *Response
}
// Returns the allowed methods and the DAV features implemented by the current server.
// For more information about the values and format read RFC4918 Sections 10.1 and 18.
func (oh optionsHandler) Handle() *Response {
// Set the DAV compliance header:
// 1: Server supports all the requirements specified in RFC2518
// 3: Server supports all the revisions specified in RFC4918
// calendar-access: Server supports all the extensions specified in RFC4791
oh.response.SetHeader("DAV", "1, 3, calendar-access").
SetHeader("Allow", "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, REPORT").
Set(http.StatusOK, "")
return oh.response
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
)
type requestPreconditions struct {
request *http.Request
}
func (p *requestPreconditions) IfMatch(etag string) bool {
etagMatch := p.request.Header["If-Match"]
return len(etagMatch) == 0 || etagMatch[0] == "*" || etagMatch[0] == etag
}
func (p *requestPreconditions) IfMatchPresent() bool {
return len(p.request.Header["If-Match"]) != 0
}
func (p *requestPreconditions) IfNoneMatch(value string) bool {
valueMatch := p.request.Header["If-None-Match"]
return len(valueMatch) == 1 && valueMatch[0] == value
}

View file

@ -0,0 +1,49 @@
package handlers
import (
"encoding/xml"
"github.com/samedi/caldav-go/global"
"net/http"
)
type propfindHandler struct {
request *http.Request
response *Response
}
func (ph propfindHandler) Handle() *Response {
requestBody := readRequestBody(ph.request)
header := headers{ph.request.Header}
// get the target resources based on the request URL
resources, err := global.Storage.GetResources(ph.request.URL.Path, header.IsDeep())
if err != nil {
return ph.response.SetError(err)
}
// read body string to xml struct
type XMLProp2 struct {
Tags []xml.Name `xml:",any"`
}
type XMLRoot2 struct {
XMLName xml.Name
Prop XMLProp2 `xml:"DAV: prop"`
}
var requestXML XMLRoot2
xml.Unmarshal([]byte(requestBody), &requestXML)
multistatus := &multistatusResp{
Minimal: header.IsMinimal(),
}
// for each href, build the multistatus responses
for _, resource := range resources {
propstats := multistatus.Propstats(&resource, requestXML.Prop.Tags)
multistatus.AddResponse(resource.Path, true, propstats)
}
if multistatus.Minimal {
ph.response.SetHeader(HD_PREFERENCE_APPLIED, HD_PREFER_MINIMAL)
}
return ph.response.Set(207, multistatus.ToXML())
}

65
vendor/github.com/samedi/caldav-go/handlers/put.go generated vendored Normal file
View file

@ -0,0 +1,65 @@
package handlers
import (
"github.com/samedi/caldav-go/errs"
"github.com/samedi/caldav-go/global"
"net/http"
)
type putHandler struct {
request *http.Request
response *Response
}
func (ph putHandler) Handle() *Response {
requestBody := readRequestBody(ph.request)
precond := requestPreconditions{ph.request}
success := false
// check if resource exists
resourcePath := ph.request.URL.Path
resource, found, err := global.Storage.GetShallowResource(resourcePath)
if err != nil && err != errs.ResourceNotFoundError {
return ph.response.SetError(err)
}
// PUT is allowed in 2 cases:
//
// 1. Item NOT FOUND and there is NO ETAG match header: CREATE a new item
if !found && !precond.IfMatchPresent() {
// create new event resource
resource, err = global.Storage.CreateResource(resourcePath, requestBody)
if err != nil {
return ph.response.SetError(err)
}
success = true
}
if found {
// TODO: Handle PUT on collections
if resource.IsCollection() {
return ph.response.Set(http.StatusPreconditionFailed, "")
}
// 2. Item exists, the resource etag is verified and there's no IF-NONE-MATCH=* header: UPDATE the item
resourceEtag, _ := resource.GetEtag()
if found && precond.IfMatch(resourceEtag) && !precond.IfNoneMatch("*") {
// update resource
resource, err = global.Storage.UpdateResource(resourcePath, requestBody)
if err != nil {
return ph.response.SetError(err)
}
success = true
}
}
if !success {
return ph.response.Set(http.StatusPreconditionFailed, "")
}
resourceEtag, _ := resource.GetEtag()
return ph.response.SetHeader("ETag", resourceEtag).
Set(http.StatusCreated, "")
}

168
vendor/github.com/samedi/caldav-go/handlers/report.go generated vendored Normal file
View file

@ -0,0 +1,168 @@
package handlers
import (
"encoding/xml"
"fmt"
"net/http"
"strings"
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/global"
"github.com/samedi/caldav-go/ixml"
)
type reportHandler struct {
request *http.Request
response *Response
}
// See more at RFC4791#section-7.1
func (rh reportHandler) Handle() *Response {
requestBody := readRequestBody(rh.request)
header := headers{rh.request.Header}
urlResource, found, err := global.Storage.GetShallowResource(rh.request.URL.Path)
if !found {
return rh.response.Set(http.StatusNotFound, "")
} else if err != nil {
return rh.response.SetError(err)
}
// read body string to xml struct
var requestXML reportRootXML
xml.Unmarshal([]byte(requestBody), &requestXML)
// The resources to be reported are fetched by the type of the request. If it is
// a `calendar-multiget`, the resources come based on a set of `hrefs` in the request body.
// If it is a `calendar-query`, the resources are calculated based on set of filters in the request.
var resourcesToReport []reportRes
switch requestXML.XMLName {
case ixml.CALENDAR_MULTIGET_TG:
resourcesToReport, err = rh.fetchResourcesByList(urlResource, requestXML.Hrefs)
case ixml.CALENDAR_QUERY_TG:
resourcesToReport, err = rh.fetchResourcesByFilters(urlResource, requestXML.Filters)
default:
return rh.response.Set(http.StatusPreconditionFailed, "")
}
if err != nil {
return rh.response.SetError(err)
}
multistatus := &multistatusResp{
Minimal: header.IsMinimal(),
}
// for each href, build the multistatus responses
for _, r := range resourcesToReport {
propstats := multistatus.Propstats(r.resource, requestXML.Prop.Tags)
multistatus.AddResponse(r.href, r.found, propstats)
}
if multistatus.Minimal {
rh.response.SetHeader(HD_PREFERENCE_APPLIED, HD_PREFER_MINIMAL)
}
return rh.response.Set(207, multistatus.ToXML())
}
type reportPropXML struct {
Tags []xml.Name `xml:",any"`
}
type reportRootXML struct {
XMLName xml.Name
Prop reportPropXML `xml:"DAV: prop"`
Hrefs []string `xml:"DAV: href"`
Filters reportFilterXML `xml:"urn:ietf:params:xml:ns:caldav filter"`
}
type reportFilterXML struct {
XMLName xml.Name
InnerContent string `xml:",innerxml"`
}
func (rfXml reportFilterXML) toString() string {
return fmt.Sprintf("<%s>%s</%s>", rfXml.XMLName.Local, rfXml.InnerContent, rfXml.XMLName.Local)
}
// Wraps a resource that has to be reported, either fetched by filters or by a list.
// Basically it contains the original requested `href`, the actual `resource` (can be nil)
// and if the `resource` was `found` or not
type reportRes struct {
href string
resource *data.Resource
found bool
}
// The resources are fetched based on the origin resource and a set of filters.
// If the origin resource is a collection, the filters are checked against each of the collection's resources
// to see if they match. The collection's resources that match the filters are returned. The ones that will be returned
// are the resources that were not found (does not exist) and the ones that matched the filters. The ones that did not
// match the filter will not appear in the response result.
// If the origin resource is not a collection, the function just returns it and ignore any filter processing.
// [See RFC4791#section-7.8]
func (rh reportHandler) fetchResourcesByFilters(origin *data.Resource, filtersXML reportFilterXML) ([]reportRes, error) {
// The list of resources that has to be reported back in the response.
reps := []reportRes{}
if origin.IsCollection() {
filters, _ := data.ParseResourceFilters(filtersXML.toString())
resources, err := global.Storage.GetResourcesByFilters(origin.Path, filters)
if err != nil {
return reps, err
}
for in, resource := range resources {
reps = append(reps, reportRes{resource.Path, &resources[in], true})
}
} else {
// the origin resource is not a collection, so returns just that as the result
reps = append(reps, reportRes{origin.Path, origin, true})
}
return reps, nil
}
// The hrefs can come from (1) the request URL or (2) from the request body itself.
// If the origin resource from the URL points to a collection (2), we will check the request body
// to get the requested `hrefs` (resource paths). Each requested href has to be related to the collection.
// The ones that are not, we simply ignore them.
// If the resource from the URL is NOT a collection (1) we process the the report only for this resource
// and ignore any othre requested hrefs that might be present in the request body.
// [See RFC4791#section-7.9]
func (rh reportHandler) fetchResourcesByList(origin *data.Resource, requestedPaths []string) ([]reportRes, error) {
reps := []reportRes{}
if origin.IsCollection() {
resources, err := global.Storage.GetResourcesByList(requestedPaths)
if err != nil {
return reps, err
}
// we put all the resources found in a map path -> resource.
// this will be used later to query which requested resource was found
// or not and mount the response
resourcesMap := make(map[string]*data.Resource)
for _, resource := range resources {
r := resource
resourcesMap[resource.Path] = &r
}
for _, requestedPath := range requestedPaths {
// if the requested path does not belong to the origin collection, skip
// ('belonging' means that the path's prefix is the same as the collection path)
if !strings.HasPrefix(requestedPath, origin.Path) {
continue
}
resource, found := resourcesMap[requestedPath]
reps = append(reps, reportRes{requestedPath, resource, found})
}
} else {
reps = append(reps, reportRes{origin.Path, origin, true})
}
return reps, nil
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"github.com/samedi/caldav-go/errs"
"io"
"net/http"
)
// Response represents the handled CalDAV response. Used this when one needs to proxy the generated
// response before being sent back to the client.
type Response struct {
Status int
Header http.Header
Body string
Error error
}
// NewResponse initializes a new response object.
func NewResponse() *Response {
return &Response{
Header: make(http.Header),
}
}
// Set sets the the status and body of the response.
func (r *Response) Set(status int, body string) *Response {
r.Status = status
r.Body = body
return r
}
// SetHeader adds a header to the response.
func (r *Response) SetHeader(key, value string) *Response {
r.Header.Set(key, value)
return r
}
// SetError sets the response as an error. It inflects the response status based on the provided error.
func (r *Response) SetError(err error) *Response {
r.Error = err
switch err {
case errs.ResourceNotFoundError:
r.Status = http.StatusNotFound
case errs.UnauthorizedError:
r.Status = http.StatusUnauthorized
case errs.ForbiddenError:
r.Status = http.StatusForbidden
default:
r.Status = http.StatusInternalServerError
}
return r
}
// Write writes the response back to the client using the provided `ResponseWriter`.
func (r *Response) Write(writer http.ResponseWriter) {
if r.Error == errs.UnauthorizedError {
r.SetHeader("WWW-Authenticate", `Basic realm="Restricted"`)
}
for key, values := range r.Header {
for _, value := range values {
writer.Header().Set(key, value)
}
}
writer.WriteHeader(r.Status)
io.WriteString(writer, r.Body)
}

18
vendor/github.com/samedi/caldav-go/handlers/shared.go generated vendored Normal file
View file

@ -0,0 +1,18 @@
package handlers
import (
"bytes"
"io/ioutil"
"net/http"
)
// This function reads the request body and restore its content, so that
// the request body can be read a second time.
func readRequestBody(request *http.Request) string {
// Read the content
body, _ := ioutil.ReadAll(request.Body)
// Restore the io.ReadCloser to its original state
request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
// Use the content
return string(body)
}

94
vendor/github.com/samedi/caldav-go/ixml/ixml.go generated vendored Normal file
View file

@ -0,0 +1,94 @@
package ixml
import (
"bytes"
"encoding/xml"
"fmt"
"net/http"
"github.com/samedi/caldav-go/lib"
)
const (
DAV_NS = "DAV:"
CALDAV_NS = "urn:ietf:params:xml:ns:caldav"
CALSERV_NS = "http://calendarserver.org/ns/"
)
var NS_PREFIXES = map[string]string{
DAV_NS: "D",
CALDAV_NS: "C",
CALSERV_NS: "CS",
}
var (
CALENDAR_TG = xml.Name{CALDAV_NS, "calendar"}
CALENDAR_DATA_TG = xml.Name{CALDAV_NS, "calendar-data"}
CALENDAR_HOME_SET_TG = xml.Name{CALDAV_NS, "calendar-home-set"}
CALENDAR_QUERY_TG = xml.Name{CALDAV_NS, "calendar-query"}
CALENDAR_MULTIGET_TG = xml.Name{CALDAV_NS, "calendar-multiget"}
CALENDAR_USER_ADDRESS_SET_TG = xml.Name{CALDAV_NS, "calendar-user-address-set"}
COLLECTION_TG = xml.Name{DAV_NS, "collection"}
CURRENT_USER_PRINCIPAL_TG = xml.Name{DAV_NS, "current-user-principal"}
DISPLAY_NAME_TG = xml.Name{DAV_NS, "displayname"}
GET_CONTENT_LENGTH_TG = xml.Name{DAV_NS, "getcontentlength"}
GET_CONTENT_TYPE_TG = xml.Name{DAV_NS, "getcontenttype"}
GET_CTAG_TG = xml.Name{CALSERV_NS, "getctag"}
GET_ETAG_TG = xml.Name{DAV_NS, "getetag"}
GET_LAST_MODIFIED_TG = xml.Name{DAV_NS, "getlastmodified"}
HREF_TG = xml.Name{DAV_NS, "href"}
OWNER_TG = xml.Name{DAV_NS, "owner"}
PRINCIPAL_TG = xml.Name{DAV_NS, "principal"}
PRINCIPAL_COLLECTION_SET_TG = xml.Name{DAV_NS, "principal-collection-set"}
PRINCIPAL_URL_TG = xml.Name{DAV_NS, "principal-URL"}
RESOURCE_TYPE_TG = xml.Name{DAV_NS, "resourcetype"}
STATUS_TG = xml.Name{DAV_NS, "status"}
SUPPORTED_CALENDAR_COMPONENT_SET_TG = xml.Name{CALDAV_NS, "supported-calendar-component-set"}
)
// Namespaces returns the default XML namespaces in for CalDAV contents.
func Namespaces() string {
bf := new(lib.StringBuffer)
bf.Write(`xmlns:%s="%s" `, NS_PREFIXES[DAV_NS], DAV_NS)
bf.Write(`xmlns:%s="%s" `, NS_PREFIXES[CALDAV_NS], CALDAV_NS)
bf.Write(`xmlns:%s="%s"`, NS_PREFIXES[CALSERV_NS], CALSERV_NS)
return bf.String()
}
// Tag returns a XML tag as string based on the given tag name and content. It
// takes in consideration the namespace and also if it is an empty content or not.
func Tag(xmlName xml.Name, content string) string {
name := xmlName.Local
ns := NS_PREFIXES[xmlName.Space]
if ns != "" {
ns = ns + ":"
}
if content != "" {
return fmt.Sprintf("<%s%s>%s</%s%s>", ns, name, content, ns, name)
} else {
return fmt.Sprintf("<%s%s/>", ns, name)
}
}
// HrefTag returns a DAV <D:href> tag with the given href path.
func HrefTag(href string) (tag string) {
return Tag(HREF_TG, href)
}
// StatusTag returns a DAV <D:status> tag with the given HTTP status. The
// status is translated into a label, e.g.: HTTP/1.1 404 NotFound.
func StatusTag(status int) string {
statusText := fmt.Sprintf("HTTP/1.1 %d %s", status, http.StatusText(status))
return Tag(STATUS_TG, statusText)
}
// EscapeText escapes any special character in the given text and returns the result.
func EscapeText(text string) string {
buffer := bytes.NewBufferString("")
xml.EscapeText(buffer, []byte(text))
return buffer.String()
}

8
vendor/github.com/samedi/caldav-go/lib/components.go generated vendored Normal file
View file

@ -0,0 +1,8 @@
package lib
const (
VCALENDAR = "VCALENDAR"
VEVENT = "VEVENT"
VJOURNAL = "VJOURNAL"
VTODO = "VTODO"
)

10
vendor/github.com/samedi/caldav-go/lib/paths.go generated vendored Normal file
View file

@ -0,0 +1,10 @@
package lib
import (
"path/filepath"
)
func ToSlashPath(path string) string {
cleanPath := filepath.Clean(path)
return filepath.ToSlash(cleanPath)
}

18
vendor/github.com/samedi/caldav-go/lib/strbuff.go generated vendored Normal file
View file

@ -0,0 +1,18 @@
package lib
import (
"bytes"
"fmt"
)
type StringBuffer struct {
buffer bytes.Buffer
}
func (b *StringBuffer) Write(format string, elem ...interface{}) {
b.buffer.WriteString(fmt.Sprintf(format, elem...))
}
func (b *StringBuffer) String() string {
return b.buffer.String()
}

4
vendor/github.com/samedi/caldav-go/test.sh generated vendored Normal file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
go test -race ./...
rm -rf test-data

5
vendor/github.com/samedi/caldav-go/version.go generated vendored Normal file
View file

@ -0,0 +1,5 @@
package caldav
const (
VERSION = "3.0.0"
)

15
vendor/modules.txt vendored
View file

@ -12,6 +12,8 @@ github.com/alecthomas/template
github.com/alecthomas/template/parse
# github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
github.com/asaskevich/govalidator
# github.com/beevik/etree v1.1.0
github.com/beevik/etree
# github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973
github.com/beorn7/perks/quantile
# github.com/client9/misspell v0.3.4
@ -78,7 +80,7 @@ github.com/inconshreveable/mousetrap
# github.com/jgautheron/goconst v0.0.0-20170703170152-9740945f5dcb
github.com/jgautheron/goconst/cmd/goconst
github.com/jgautheron/goconst
# github.com/labstack/echo/v4 v4.1.5
# github.com/labstack/echo/v4 v4.1.5 => ../../github.com/labstack/echo
github.com/labstack/echo/v4
github.com/labstack/echo/v4/middleware
# github.com/labstack/gommon v0.2.8
@ -86,6 +88,8 @@ github.com/labstack/gommon/log
github.com/labstack/gommon/color
github.com/labstack/gommon/bytes
github.com/labstack/gommon/random
# github.com/laurent22/ical-go v0.1.0
github.com/laurent22/ical-go/ical
# github.com/magiconair/properties v1.8.0
github.com/magiconair/properties
# github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983
@ -130,6 +134,15 @@ github.com/prometheus/procfs
github.com/prometheus/procfs/nfs
github.com/prometheus/procfs/xfs
github.com/prometheus/procfs/internal/util
# github.com/samedi/caldav-go v3.0.0+incompatible => ../../github.com/samedi/caldav-go
github.com/samedi/caldav-go
github.com/samedi/caldav-go/data
github.com/samedi/caldav-go/errs
github.com/samedi/caldav-go/lib
github.com/samedi/caldav-go/global
github.com/samedi/caldav-go/handlers
github.com/samedi/caldav-go/files
github.com/samedi/caldav-go/ixml
# github.com/spf13/afero v1.2.2
github.com/spf13/afero
github.com/spf13/afero/mem