diff --git a/.drone1.yml b/.drone1.yml index 4334e2b5..9d607c83 100644 --- a/.drone1.yml +++ b/.drone1.yml @@ -26,6 +26,8 @@ steps: - name: build image: vikunja/golang-build:latest pull: true + environment: + GOFLAGS: '-mod=vendor' commands: - make lint - make fmt-check diff --git a/Featurecreep.md b/Featurecreep.md index 48ba26de..872950db 100644 --- a/Featurecreep.md +++ b/Featurecreep.md @@ -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 diff --git a/config.yml.sample b/config.yml.sample index 5a8374e5..a796bce8 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -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. diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index a119dc25..03a9a5c1 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -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. diff --git a/docs/content/doc/usage/caldav.md b/docs/content/doc/usage/caldav.md new file mode 100644 index 00000000..bb0e7d6a --- /dev/null +++ b/docs/content/doc/usage/caldav.md @@ -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//`: Returns urls for list discovery. *Use this url to initially make connections to new clients.* +* `/lists/`: Used to manage lists +* `/lists//`: Used to manage a single list +* `/lists//`: 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: +[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: +[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... + + +``` \ No newline at end of file diff --git a/go.mod b/go.mod index b54373ac..d02ee3a6 100644 --- a/go.mod +++ b/go.mod @@ -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 +//) diff --git a/go.sum b/go.sum index 27efdecc..9ae92640 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/caldav/caldav.go b/pkg/caldav/caldav.go index a7f11a98..2d8b9156 100644 --- a/pkg/caldav/caldav.go +++ b/pkg/caldav/caldav.go @@ -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) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 3415db6a..30902384 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 { diff --git a/pkg/integrations/task_test.go b/pkg/integrations/task_test.go index c4a1b383..087a5814 100644 --- a/pkg/integrations/task_test.go +++ b/pkg/integrations/task_test.go @@ -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 diff --git a/pkg/migration/20190511202210.go b/pkg/migration/20190511202210.go new file mode 100644 index 00000000..f709dba2 --- /dev/null +++ b/pkg/migration/20190511202210.go @@ -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 . + +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") + }, + }) +} diff --git a/pkg/migration/20190514192749.go b/pkg/migration/20190514192749.go new file mode 100644 index 00000000..b48b9790 --- /dev/null +++ b/pkg/migration/20190514192749.go @@ -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 . + +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") + }, + }) +} diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index c0df0396..630b6d20 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -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 } diff --git a/pkg/models/label_task_rights.go b/pkg/models/label_task_rights.go index 23703430..5b7b83dc 100644 --- a/pkg/models/label_task_rights.go +++ b/pkg/models/label_task_rights.go @@ -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 } diff --git a/pkg/models/label_task_test.go b/pkg/models/label_task_test.go index fbd0d1f8..f986ce43 100644 --- a/pkg/models/label_task_test.go +++ b/pkg/models/label_task_test.go @@ -166,6 +166,8 @@ func TestLabelTask_Create(t *testing.T) { a: &User{ID: 1}, }, wantForbidden: true, + wantErr: true, + errType: IsErrListTaskDoesNotExist, }, } for _, tt := range tests { diff --git a/pkg/models/list_create_update.go b/pkg/models/list_create_update.go index bfe3bf42..4de57538 100644 --- a/pkg/models/list_create_update.go +++ b/pkg/models/list_create_update.go @@ -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. diff --git a/pkg/models/list_task_assignees.go b/pkg/models/list_task_assignees.go index fef70b22..f6bb227c 100644 --- a/pkg/models/list_task_assignees.go +++ b/pkg/models/list_task_assignees.go @@ -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 } diff --git a/pkg/models/list_tasks.go b/pkg/models/list_tasks.go index 710fc3d6..7282572c 100644 --- a/pkg/models/list_tasks.go +++ b/pkg/models/list_tasks.go @@ -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 } diff --git a/pkg/models/list_tasks_create_update.go b/pkg/models/list_tasks_create_update.go index 92d0595d..a2997406 100644 --- a/pkg/models/list_tasks_create_update.go +++ b/pkg/models/list_tasks_create_update.go @@ -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 + } } diff --git a/pkg/models/list_tasks_delete.go b/pkg/models/list_tasks_delete.go index 60d2a687..8ed5279a 100644 --- a/pkg/models/list_tasks_delete.go +++ b/pkg/models/list_tasks_delete.go @@ -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 } diff --git a/pkg/models/list_tasks_rights.go b/pkg/models/list_tasks_rights.go index b0bf1ba2..4152763c 100644 --- a/pkg/models/list_tasks_rights.go +++ b/pkg/models/list_tasks_rights.go @@ -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 } diff --git a/pkg/models/list_tasks_test.go b/pkg/models/list_tasks_test.go index 7d87070a..1ac80c24 100644 --- a/pkg/models/list_tasks_test.go +++ b/pkg/models/list_tasks_test.go @@ -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) + }) +} diff --git a/pkg/models/list_users_create.go b/pkg/models/list_users_create.go index 75db5eff..fc141eda 100644 --- a/pkg/models/list_users_create.go +++ b/pkg/models/list_users_create.go @@ -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 } diff --git a/pkg/models/list_users_delete.go b/pkg/models/list_users_delete.go index eca0b360..a44a81c2 100644 --- a/pkg/models/list_users_delete.go +++ b/pkg/models/list_users_delete.go @@ -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 } diff --git a/pkg/models/list_users_update.go b/pkg/models/list_users_update.go index f5ef2ed6..a69c5891 100644 --- a/pkg/models/list_users_update.go +++ b/pkg/models/list_users_update.go @@ -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 } diff --git a/pkg/models/team_list_create.go b/pkg/models/team_list_create.go index 76d40c61..ab72263a 100644 --- a/pkg/models/team_list_create.go +++ b/pkg/models/team_list_create.go @@ -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 } diff --git a/pkg/models/team_list_delete.go b/pkg/models/team_list_delete.go index fd64332e..ccae1701 100644 --- a/pkg/models/team_list_delete.go +++ b/pkg/models/team_list_delete.go @@ -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 } diff --git a/pkg/models/team_list_update.go b/pkg/models/team_list_update.go index 6b7e8458..17bb260a 100644 --- a/pkg/models/team_list_update.go +++ b/pkg/models/team_list_update.go @@ -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 } diff --git a/pkg/models/user_add_update.go b/pkg/models/user_add_update.go index 578771fd..2c71a5b6 100644 --- a/pkg/models/user_add_update.go +++ b/pkg/models/user_add_update.go @@ -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 } diff --git a/pkg/routes/api/v1/caldav.go b/pkg/routes/api/v1/caldav.go deleted file mode 100644 index a6fa410a..00000000 --- a/pkg/routes/api/v1/caldav.go +++ /dev/null @@ -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 . - -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)) -} diff --git a/pkg/routes/caldav/handler.go b/pkg/routes/caldav/handler.go new file mode 100644 index 00000000..7b73135f --- /dev/null +++ b/pkg/routes/caldav/handler.go @@ -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 . + +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 +} diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go new file mode 100644 index 00000000..7d0a4dd3 --- /dev/null +++ b/pkg/routes/caldav/listStorageProvider.go @@ -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 . + +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 +} diff --git a/pkg/routes/caldav/parsing.go b/pkg/routes/caldav/parsing.go new file mode 100644 index 00000000..1b3fee7b --- /dev/null +++ b/pkg/routes/caldav/parsing.go @@ -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 . + +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() +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index aff87d4a..fa2aa0c1 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -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 +} diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 3af72afa..d49f7094 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -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" diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index c02a5695..ca3c8f42 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -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" diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 22a75db0..8c680dd3 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -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. - # Authorization - **JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + "`" + `Authorization: Bearer ` + "`" + `-header to authenticate successfully. - - **BasicAuth:** Only used when requesting tasks via caldav. - + description: '' 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: diff --git a/vendor/github.com/beevik/etree/.travis.yml b/vendor/github.com/beevik/etree/.travis.yml new file mode 100644 index 00000000..f4cb25d4 --- /dev/null +++ b/vendor/github.com/beevik/etree/.travis.yml @@ -0,0 +1,14 @@ +language: go +sudo: false + +go: + - 1.11.x + - tip + +matrix: + allow_failures: + - go: tip + +script: + - go vet ./... + - go test -v ./... diff --git a/vendor/github.com/beevik/etree/CONTRIBUTORS b/vendor/github.com/beevik/etree/CONTRIBUTORS new file mode 100644 index 00000000..03211a85 --- /dev/null +++ b/vendor/github.com/beevik/etree/CONTRIBUTORS @@ -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) diff --git a/vendor/github.com/beevik/etree/LICENSE b/vendor/github.com/beevik/etree/LICENSE new file mode 100644 index 00000000..26f1f775 --- /dev/null +++ b/vendor/github.com/beevik/etree/LICENSE @@ -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. diff --git a/vendor/github.com/beevik/etree/README.md b/vendor/github.com/beevik/etree/README.md new file mode 100644 index 00000000..08ec26b0 --- /dev/null +++ b/vendor/github.com/beevik/etree/README.md @@ -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 + + + + + + + +``` + +### Reading an XML file + +Suppose you have a file on disk called `bookstore.xml` containing the +following data: + +```xml + + + + Everyday Italian + Giada De Laurentiis + 2005 + 30.00 + + + + Harry Potter + J K. Rowling + 2005 + 29.99 + + + + XQuery Kick Start + James McGovern + Per Bothner + Kurt Cagle + James Linn + Vaidyanathan Nagarajan + 2003 + 49.99 + + + + Learning XML + Erik T. Ray + 2003 + 39.95 + + + +``` + +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! diff --git a/vendor/github.com/beevik/etree/RELEASE_NOTES.md b/vendor/github.com/beevik/etree/RELEASE_NOTES.md new file mode 100644 index 00000000..ee59d7ab --- /dev/null +++ b/vendor/github.com/beevik/etree/RELEASE_NOTES.md @@ -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. diff --git a/vendor/github.com/beevik/etree/etree.go b/vendor/github.com/beevik/etree/etree.go new file mode 100644 index 00000000..9e24f901 --- /dev/null +++ b/vendor/github.com/beevik/etree/etree.go @@ -0,0 +1,1453 @@ +// 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 provides XML services through an Element Tree +// abstraction. +package etree + +import ( + "bufio" + "bytes" + "encoding/xml" + "errors" + "io" + "os" + "sort" + "strings" +) + +const ( + // NoIndent is used with Indent to disable all indenting. + NoIndent = -1 +) + +// ErrXML is returned when XML parsing fails due to incorrect formatting. +var ErrXML = errors.New("etree: invalid XML format") + +// ReadSettings allow for changing the default behavior of the ReadFrom* +// methods. +type ReadSettings struct { + // CharsetReader to be passed to standard xml.Decoder. Default: nil. + CharsetReader func(charset string, input io.Reader) (io.Reader, error) + + // Permissive allows input containing common mistakes such as missing tags + // or attribute values. Default: false. + Permissive bool + + // Entity to be passed to standard xml.Decoder. Default: nil. + Entity map[string]string +} + +// newReadSettings creates a default ReadSettings record. +func newReadSettings() ReadSettings { + return ReadSettings{ + CharsetReader: func(label string, input io.Reader) (io.Reader, error) { + return input, nil + }, + Permissive: false, + } +} + +// WriteSettings allow for changing the serialization behavior of the WriteTo* +// methods. +type WriteSettings struct { + // CanonicalEndTags forces the production of XML end tags, even for + // elements that have no child elements. Default: false. + CanonicalEndTags bool + + // CanonicalText forces the production of XML character references for + // text data characters &, <, and >. If false, XML character references + // are also produced for " and '. Default: false. + CanonicalText bool + + // CanonicalAttrVal forces the production of XML character references for + // attribute value characters &, < and ". If false, XML character + // references are also produced for > and '. Default: false. + CanonicalAttrVal bool + + // When outputting indented XML, use a carriage return and linefeed + // ("\r\n") as a new-line delimiter instead of just a linefeed ("\n"). + // This is useful on Windows-based systems. + UseCRLF bool +} + +// newWriteSettings creates a default WriteSettings record. +func newWriteSettings() WriteSettings { + return WriteSettings{ + CanonicalEndTags: false, + CanonicalText: false, + CanonicalAttrVal: false, + UseCRLF: false, + } +} + +// A Token is an empty interface that represents an Element, CharData, +// Comment, Directive, or ProcInst. +type Token interface { + Parent() *Element + Index() int + dup(parent *Element) Token + setParent(parent *Element) + setIndex(index int) + writeTo(w *bufio.Writer, s *WriteSettings) +} + +// A Document is a container holding a complete XML hierarchy. Its embedded +// element contains zero or more children, one of which is usually the root +// element. The embedded element may include other children such as +// processing instructions or BOM CharData tokens. +type Document struct { + Element + ReadSettings ReadSettings + WriteSettings WriteSettings +} + +// An Element represents an XML element, its attributes, and its child tokens. +type Element struct { + Space, Tag string // namespace prefix and tag + Attr []Attr // key-value attribute pairs + Child []Token // child tokens (elements, comments, etc.) + parent *Element // parent element + index int // token index in parent's children +} + +// An Attr represents a key-value attribute of an XML element. +type Attr struct { + Space, Key string // The attribute's namespace prefix and key + Value string // The attribute value string + element *Element // element containing the attribute +} + +// charDataFlags are used with CharData tokens to store additional settings. +type charDataFlags uint8 + +const ( + // The CharData was created by an indent function as whitespace. + whitespaceFlag charDataFlags = 1 << iota + + // The CharData contains a CDATA section. + cdataFlag +) + +// CharData can be used to represent character data or a CDATA section within +// an XML document. +type CharData struct { + Data string + parent *Element + index int + flags charDataFlags +} + +// A Comment represents an XML comment. +type Comment struct { + Data string + parent *Element + index int +} + +// A Directive represents an XML directive. +type Directive struct { + Data string + parent *Element + index int +} + +// A ProcInst represents an XML processing instruction. +type ProcInst struct { + Target string + Inst string + parent *Element + index int +} + +// NewDocument creates an XML document without a root element. +func NewDocument() *Document { + return &Document{ + Element{Child: make([]Token, 0)}, + newReadSettings(), + newWriteSettings(), + } +} + +// Copy returns a recursive, deep copy of the document. +func (d *Document) Copy() *Document { + return &Document{*(d.dup(nil).(*Element)), d.ReadSettings, d.WriteSettings} +} + +// Root returns the root element of the document, or nil if there is no root +// element. +func (d *Document) Root() *Element { + for _, t := range d.Child { + if c, ok := t.(*Element); ok { + return c + } + } + return nil +} + +// SetRoot replaces the document's root element with e. If the document +// already has a root when this function is called, then the document's +// original root is unbound first. If the element e is bound to another +// document (or to another element within a document), then it is unbound +// first. +func (d *Document) SetRoot(e *Element) { + if e.parent != nil { + e.parent.RemoveChild(e) + } + + p := &d.Element + e.setParent(p) + + // If there is already a root element, replace it. + for i, t := range p.Child { + if _, ok := t.(*Element); ok { + t.setParent(nil) + t.setIndex(-1) + p.Child[i] = e + e.setIndex(i) + return + } + } + + // No existing root element, so add it. + p.addChild(e) +} + +// ReadFrom reads XML from the reader r into the document d. It returns the +// number of bytes read and any error encountered. +func (d *Document) ReadFrom(r io.Reader) (n int64, err error) { + return d.Element.readFrom(r, d.ReadSettings) +} + +// ReadFromFile reads XML from the string s into the document d. +func (d *Document) ReadFromFile(filename string) error { + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + _, err = d.ReadFrom(f) + return err +} + +// ReadFromBytes reads XML from the byte slice b into the document d. +func (d *Document) ReadFromBytes(b []byte) error { + _, err := d.ReadFrom(bytes.NewReader(b)) + return err +} + +// ReadFromString reads XML from the string s into the document d. +func (d *Document) ReadFromString(s string) error { + _, err := d.ReadFrom(strings.NewReader(s)) + return err +} + +// WriteTo serializes an XML document into the writer w. It +// returns the number of bytes written and any error encountered. +func (d *Document) WriteTo(w io.Writer) (n int64, err error) { + cw := newCountWriter(w) + b := bufio.NewWriter(cw) + for _, c := range d.Child { + c.writeTo(b, &d.WriteSettings) + } + err, n = b.Flush(), cw.bytes + return +} + +// WriteToFile serializes an XML document into the file named +// filename. +func (d *Document) WriteToFile(filename string) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + _, err = d.WriteTo(f) + return err +} + +// WriteToBytes serializes the XML document into a slice of +// bytes. +func (d *Document) WriteToBytes() (b []byte, err error) { + var buf bytes.Buffer + if _, err = d.WriteTo(&buf); err != nil { + return + } + return buf.Bytes(), nil +} + +// WriteToString serializes the XML document into a string. +func (d *Document) WriteToString() (s string, err error) { + var b []byte + if b, err = d.WriteToBytes(); err != nil { + return + } + return string(b), nil +} + +type indentFunc func(depth int) string + +// Indent modifies the document's element tree by inserting character data +// tokens containing newlines and indentation. The amount of indentation per +// depth level is given as spaces. Pass etree.NoIndent for spaces if you want +// no indentation at all. +func (d *Document) Indent(spaces int) { + var indent indentFunc + switch { + case spaces < 0: + indent = func(depth int) string { return "" } + case d.WriteSettings.UseCRLF == true: + indent = func(depth int) string { return indentCRLF(depth*spaces, indentSpaces) } + default: + indent = func(depth int) string { return indentLF(depth*spaces, indentSpaces) } + } + d.Element.indent(0, indent) +} + +// IndentTabs modifies the document's element tree by inserting CharData +// tokens containing newlines and tabs for indentation. One tab is used per +// indentation level. +func (d *Document) IndentTabs() { + var indent indentFunc + switch d.WriteSettings.UseCRLF { + case true: + indent = func(depth int) string { return indentCRLF(depth, indentTabs) } + default: + indent = func(depth int) string { return indentLF(depth, indentTabs) } + } + d.Element.indent(0, indent) +} + +// NewElement creates an unparented element with the specified tag. The tag +// may be prefixed by a namespace prefix and a colon. +func NewElement(tag string) *Element { + space, stag := spaceDecompose(tag) + return newElement(space, stag, nil) +} + +// newElement is a helper function that creates an element and binds it to +// a parent element if possible. +func newElement(space, tag string, parent *Element) *Element { + e := &Element{ + Space: space, + Tag: tag, + Attr: make([]Attr, 0), + Child: make([]Token, 0), + parent: parent, + index: -1, + } + if parent != nil { + parent.addChild(e) + } + return e +} + +// Copy creates a recursive, deep copy of the element and all its attributes +// and children. The returned element has no parent but can be parented to a +// another element using AddElement, or to a document using SetRoot. +func (e *Element) Copy() *Element { + return e.dup(nil).(*Element) +} + +// FullTag returns the element e's complete tag, including namespace prefix if +// present. +func (e *Element) FullTag() string { + if e.Space == "" { + return e.Tag + } + return e.Space + ":" + e.Tag +} + +// NamespaceURI returns the XML namespace URI associated with the element. If +// the element is part of the XML default namespace, NamespaceURI returns the +// empty string. +func (e *Element) NamespaceURI() string { + if e.Space == "" { + return e.findDefaultNamespaceURI() + } + return e.findLocalNamespaceURI(e.Space) +} + +// findLocalNamespaceURI finds the namespace URI corresponding to the +// requested prefix. +func (e *Element) findLocalNamespaceURI(prefix string) string { + for _, a := range e.Attr { + if a.Space == "xmlns" && a.Key == prefix { + return a.Value + } + } + + if e.parent == nil { + return "" + } + + return e.parent.findLocalNamespaceURI(prefix) +} + +// findDefaultNamespaceURI finds the default namespace URI of the element. +func (e *Element) findDefaultNamespaceURI() string { + for _, a := range e.Attr { + if a.Space == "" && a.Key == "xmlns" { + return a.Value + } + } + + if e.parent == nil { + return "" + } + + return e.parent.findDefaultNamespaceURI() +} + +// hasText returns true if the element has character data immediately +// folllowing the element's opening tag. +func (e *Element) hasText() bool { + if len(e.Child) == 0 { + return false + } + _, ok := e.Child[0].(*CharData) + return ok +} + +// namespacePrefix returns the namespace prefix associated with the element. +func (e *Element) namespacePrefix() string { + return e.Space +} + +// name returns the tag associated with the element. +func (e *Element) name() string { + return e.Tag +} + +// Text returns all character data immediately following the element's opening +// tag. +func (e *Element) Text() string { + if len(e.Child) == 0 { + return "" + } + + text := "" + for _, ch := range e.Child { + if cd, ok := ch.(*CharData); ok { + if text == "" { + text = cd.Data + } else { + text = text + cd.Data + } + } else { + break + } + } + return text +} + +// SetText replaces all character data immediately following an element's +// opening tag with the requested string. +func (e *Element) SetText(text string) { + e.replaceText(0, text, 0) +} + +// SetCData replaces all character data immediately following an element's +// opening tag with a CDATA section. +func (e *Element) SetCData(text string) { + e.replaceText(0, text, cdataFlag) +} + +// Tail returns all character data immediately following the element's end +// tag. +func (e *Element) Tail() string { + if e.Parent() == nil { + return "" + } + + p := e.Parent() + i := e.Index() + + text := "" + for _, ch := range p.Child[i+1:] { + if cd, ok := ch.(*CharData); ok { + if text == "" { + text = cd.Data + } else { + text = text + cd.Data + } + } else { + break + } + } + return text +} + +// SetTail replaces all character data immediately following the element's end +// tag with the requested string. +func (e *Element) SetTail(text string) { + if e.Parent() == nil { + return + } + + p := e.Parent() + p.replaceText(e.Index()+1, text, 0) +} + +// replaceText is a helper function that replaces a series of chardata tokens +// starting at index i with the requested text. +func (e *Element) replaceText(i int, text string, flags charDataFlags) { + end := e.findTermCharDataIndex(i) + + switch { + case end == i: + if text != "" { + // insert a new chardata token at index i + cd := newCharData(text, flags, nil) + e.InsertChildAt(i, cd) + } + + case end == i+1: + if text == "" { + // remove the chardata token at index i + e.RemoveChildAt(i) + } else { + // replace the first and only character token at index i + cd := e.Child[i].(*CharData) + cd.Data, cd.flags = text, flags + } + + default: + if text == "" { + // remove all chardata tokens starting from index i + copy(e.Child[i:], e.Child[end:]) + removed := end - i + e.Child = e.Child[:len(e.Child)-removed] + for j := i; j < len(e.Child); j++ { + e.Child[j].setIndex(j) + } + } else { + // replace the first chardata token at index i and remove all + // subsequent chardata tokens + cd := e.Child[i].(*CharData) + cd.Data, cd.flags = text, flags + copy(e.Child[i+1:], e.Child[end:]) + removed := end - (i + 1) + e.Child = e.Child[:len(e.Child)-removed] + for j := i + 1; j < len(e.Child); j++ { + e.Child[j].setIndex(j) + } + } + } +} + +// findTermCharDataIndex finds the index of the first child token that isn't +// a CharData token. It starts from the requested start index. +func (e *Element) findTermCharDataIndex(start int) int { + for i := start; i < len(e.Child); i++ { + if _, ok := e.Child[i].(*CharData); !ok { + return i + } + } + return len(e.Child) +} + +// CreateElement creates an element with the specified tag and adds it as the +// last child element of the element e. The tag may be prefixed by a namespace +// prefix and a colon. +func (e *Element) CreateElement(tag string) *Element { + space, stag := spaceDecompose(tag) + return newElement(space, stag, e) +} + +// AddChild adds the token t as the last child of element e. If token t was +// already the child of another element, it is first removed from its current +// parent element. +func (e *Element) AddChild(t Token) { + if t.Parent() != nil { + t.Parent().RemoveChild(t) + } + + t.setParent(e) + e.addChild(t) +} + +// InsertChild inserts the token t before e's existing child token ex. If ex +// is nil or ex is not a child of e, then t is added to the end of e's child +// token list. If token t was already the child of another element, it is +// first removed from its current parent element. +// +// Deprecated: InsertChild is deprecated. Use InsertChildAt instead. +func (e *Element) InsertChild(ex Token, t Token) { + if ex == nil || ex.Parent() != e { + e.AddChild(t) + return + } + + if t.Parent() != nil { + t.Parent().RemoveChild(t) + } + + t.setParent(e) + + i := ex.Index() + e.Child = append(e.Child, nil) + copy(e.Child[i+1:], e.Child[i:]) + e.Child[i] = t + + for j := i; j < len(e.Child); j++ { + e.Child[j].setIndex(j) + } +} + +// InsertChildAt inserts the token t into the element e's list of child tokens +// just before the requested index. If the index is greater than or equal to +// the length of the list of child tokens, the token t is added to the end of +// the list. +func (e *Element) InsertChildAt(index int, t Token) { + if index >= len(e.Child) { + e.AddChild(t) + return + } + + if t.Parent() != nil { + if t.Parent() == e && t.Index() > index { + index-- + } + t.Parent().RemoveChild(t) + } + + t.setParent(e) + + e.Child = append(e.Child, nil) + copy(e.Child[index+1:], e.Child[index:]) + e.Child[index] = t + + for j := index; j < len(e.Child); j++ { + e.Child[j].setIndex(j) + } +} + +// RemoveChild attempts to remove the token t from element e's list of +// children. If the token t is a child of e, then it is returned. Otherwise, +// nil is returned. +func (e *Element) RemoveChild(t Token) Token { + if t.Parent() != e { + return nil + } + return e.RemoveChildAt(t.Index()) +} + +// RemoveChildAt removes the index-th child token from the element e. The +// removed child token is returned. If the index is out of bounds, no child is +// removed and nil is returned. +func (e *Element) RemoveChildAt(index int) Token { + if index >= len(e.Child) { + return nil + } + + t := e.Child[index] + for j := index + 1; j < len(e.Child); j++ { + e.Child[j].setIndex(j - 1) + } + e.Child = append(e.Child[:index], e.Child[index+1:]...) + t.setIndex(-1) + t.setParent(nil) + return t +} + +// ReadFrom reads XML from the reader r and stores the result as a new child +// of element e. +func (e *Element) readFrom(ri io.Reader, settings ReadSettings) (n int64, err error) { + r := newCountReader(ri) + dec := xml.NewDecoder(r) + dec.CharsetReader = settings.CharsetReader + dec.Strict = !settings.Permissive + dec.Entity = settings.Entity + var stack stack + stack.push(e) + for { + t, err := dec.RawToken() + switch { + case err == io.EOF: + return r.bytes, nil + case err != nil: + return r.bytes, err + case stack.empty(): + return r.bytes, ErrXML + } + + top := stack.peek().(*Element) + + switch t := t.(type) { + case xml.StartElement: + e := newElement(t.Name.Space, t.Name.Local, top) + for _, a := range t.Attr { + e.createAttr(a.Name.Space, a.Name.Local, a.Value, e) + } + stack.push(e) + case xml.EndElement: + stack.pop() + case xml.CharData: + data := string(t) + var flags charDataFlags + if isWhitespace(data) { + flags = whitespaceFlag + } + newCharData(data, flags, top) + case xml.Comment: + newComment(string(t), top) + case xml.Directive: + newDirective(string(t), top) + case xml.ProcInst: + newProcInst(t.Target, string(t.Inst), top) + } + } +} + +// SelectAttr finds an element attribute matching the requested key and +// returns it if found. Returns nil if no matching attribute is found. The key +// may be prefixed by a namespace prefix and a colon. +func (e *Element) SelectAttr(key string) *Attr { + space, skey := spaceDecompose(key) + for i, a := range e.Attr { + if spaceMatch(space, a.Space) && skey == a.Key { + return &e.Attr[i] + } + } + return nil +} + +// SelectAttrValue finds an element attribute matching the requested key and +// returns its value if found. The key may be prefixed by a namespace prefix +// and a colon. If the key is not found, the dflt value is returned instead. +func (e *Element) SelectAttrValue(key, dflt string) string { + space, skey := spaceDecompose(key) + for _, a := range e.Attr { + if spaceMatch(space, a.Space) && skey == a.Key { + return a.Value + } + } + return dflt +} + +// ChildElements returns all elements that are children of element e. +func (e *Element) ChildElements() []*Element { + var elements []*Element + for _, t := range e.Child { + if c, ok := t.(*Element); ok { + elements = append(elements, c) + } + } + return elements +} + +// SelectElement returns the first child element with the given tag. The tag +// may be prefixed by a namespace prefix and a colon. Returns nil if no +// element with a matching tag was found. +func (e *Element) SelectElement(tag string) *Element { + space, stag := spaceDecompose(tag) + for _, t := range e.Child { + if c, ok := t.(*Element); ok && spaceMatch(space, c.Space) && stag == c.Tag { + return c + } + } + return nil +} + +// SelectElements returns a slice of all child elements with the given tag. +// The tag may be prefixed by a namespace prefix and a colon. +func (e *Element) SelectElements(tag string) []*Element { + space, stag := spaceDecompose(tag) + var elements []*Element + for _, t := range e.Child { + if c, ok := t.(*Element); ok && spaceMatch(space, c.Space) && stag == c.Tag { + elements = append(elements, c) + } + } + return elements +} + +// FindElement returns the first element matched by the XPath-like path +// string. Returns nil if no element is found using the path. Panics if an +// invalid path string is supplied. +func (e *Element) FindElement(path string) *Element { + return e.FindElementPath(MustCompilePath(path)) +} + +// FindElementPath returns the first element matched by the XPath-like path +// string. Returns nil if no element is found using the path. +func (e *Element) FindElementPath(path Path) *Element { + p := newPather() + elements := p.traverse(e, path) + switch { + case len(elements) > 0: + return elements[0] + default: + return nil + } +} + +// FindElements returns a slice of elements matched by the XPath-like path +// string. Panics if an invalid path string is supplied. +func (e *Element) FindElements(path string) []*Element { + return e.FindElementsPath(MustCompilePath(path)) +} + +// FindElementsPath returns a slice of elements matched by the Path object. +func (e *Element) FindElementsPath(path Path) []*Element { + p := newPather() + return p.traverse(e, path) +} + +// GetPath returns the absolute path of the element. +func (e *Element) GetPath() string { + path := []string{} + for seg := e; seg != nil; seg = seg.Parent() { + if seg.Tag != "" { + path = append(path, seg.Tag) + } + } + + // Reverse the path. + for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { + path[i], path[j] = path[j], path[i] + } + + return "/" + strings.Join(path, "/") +} + +// GetRelativePath returns the path of the element relative to the source +// element. If the two elements are not part of the same element tree, then +// GetRelativePath returns the empty string. +func (e *Element) GetRelativePath(source *Element) string { + var path []*Element + + if source == nil { + return "" + } + + // Build a reverse path from the element toward the root. Stop if the + // source element is encountered. + var seg *Element + for seg = e; seg != nil && seg != source; seg = seg.Parent() { + path = append(path, seg) + } + + // If we found the source element, reverse the path and compose the + // string. + if seg == source { + if len(path) == 0 { + return "." + } + parts := []string{} + for i := len(path) - 1; i >= 0; i-- { + parts = append(parts, path[i].Tag) + } + return "./" + strings.Join(parts, "/") + } + + // The source wasn't encountered, so climb from the source element toward + // the root of the tree until an element in the reversed path is + // encountered. + + findPathIndex := func(e *Element, path []*Element) int { + for i, ee := range path { + if e == ee { + return i + } + } + return -1 + } + + climb := 0 + for seg = source; seg != nil; seg = seg.Parent() { + i := findPathIndex(seg, path) + if i >= 0 { + path = path[:i] // truncate at found segment + break + } + climb++ + } + + // No element in the reversed path was encountered, so the two elements + // must not be part of the same tree. + if seg == nil { + return "" + } + + // Reverse the (possibly truncated) path and prepend ".." segments to + // climb. + parts := []string{} + for i := 0; i < climb; i++ { + parts = append(parts, "..") + } + for i := len(path) - 1; i >= 0; i-- { + parts = append(parts, path[i].Tag) + } + return strings.Join(parts, "/") +} + +// indent recursively inserts proper indentation between an +// XML element's child tokens. +func (e *Element) indent(depth int, indent indentFunc) { + e.stripIndent() + n := len(e.Child) + if n == 0 { + return + } + + oldChild := e.Child + e.Child = make([]Token, 0, n*2+1) + isCharData, firstNonCharData := false, true + for _, c := range oldChild { + // Insert NL+indent before child if it's not character data. + // Exceptions: when it's the first non-character-data child, or when + // the child is at root depth. + _, isCharData = c.(*CharData) + if !isCharData { + if !firstNonCharData || depth > 0 { + s := indent(depth) + if s != "" { + newCharData(s, whitespaceFlag, e) + } + } + firstNonCharData = false + } + + e.addChild(c) + + // Recursively process child elements. + if ce, ok := c.(*Element); ok { + ce.indent(depth+1, indent) + } + } + + // Insert NL+indent before the last child. + if !isCharData { + if !firstNonCharData || depth > 0 { + s := indent(depth - 1) + if s != "" { + newCharData(s, whitespaceFlag, e) + } + } + } +} + +// stripIndent removes any previously inserted indentation. +func (e *Element) stripIndent() { + // Count the number of non-indent child tokens + n := len(e.Child) + for _, c := range e.Child { + if cd, ok := c.(*CharData); ok && cd.IsWhitespace() { + n-- + } + } + if n == len(e.Child) { + return + } + + // Strip out indent CharData + newChild := make([]Token, n) + j := 0 + for _, c := range e.Child { + if cd, ok := c.(*CharData); ok && cd.IsWhitespace() { + continue + } + newChild[j] = c + newChild[j].setIndex(j) + j++ + } + e.Child = newChild +} + +// dup duplicates the element. +func (e *Element) dup(parent *Element) Token { + ne := &Element{ + Space: e.Space, + Tag: e.Tag, + Attr: make([]Attr, len(e.Attr)), + Child: make([]Token, len(e.Child)), + parent: parent, + index: e.index, + } + for i, t := range e.Child { + ne.Child[i] = t.dup(ne) + } + for i, a := range e.Attr { + ne.Attr[i] = a + } + return ne +} + +// Parent returns the element token's parent element, or nil if it has no +// parent. +func (e *Element) Parent() *Element { + return e.parent +} + +// Index returns the index of this element within its parent element's +// list of child tokens. If this element has no parent element, the index +// is -1. +func (e *Element) Index() int { + return e.index +} + +// setParent replaces the element token's parent. +func (e *Element) setParent(parent *Element) { + e.parent = parent +} + +// setIndex sets the element token's index within its parent's Child slice. +func (e *Element) setIndex(index int) { + e.index = index +} + +// writeTo serializes the element to the writer w. +func (e *Element) writeTo(w *bufio.Writer, s *WriteSettings) { + w.WriteByte('<') + w.WriteString(e.FullTag()) + for _, a := range e.Attr { + w.WriteByte(' ') + a.writeTo(w, s) + } + if len(e.Child) > 0 { + w.WriteString(">") + for _, c := range e.Child { + c.writeTo(w, s) + } + w.Write([]byte{'<', '/'}) + w.WriteString(e.FullTag()) + w.WriteByte('>') + } else { + if s.CanonicalEndTags { + w.Write([]byte{'>', '<', '/'}) + w.WriteString(e.FullTag()) + w.WriteByte('>') + } else { + w.Write([]byte{'/', '>'}) + } + } +} + +// addChild adds a child token to the element e. +func (e *Element) addChild(t Token) { + t.setIndex(len(e.Child)) + e.Child = append(e.Child, t) +} + +// CreateAttr creates an attribute and adds it to element e. The key may be +// prefixed by a namespace prefix and a colon. If an attribute with the key +// already exists, its value is replaced. +func (e *Element) CreateAttr(key, value string) *Attr { + space, skey := spaceDecompose(key) + return e.createAttr(space, skey, value, e) +} + +// createAttr is a helper function that creates attributes. +func (e *Element) createAttr(space, key, value string, parent *Element) *Attr { + for i, a := range e.Attr { + if space == a.Space && key == a.Key { + e.Attr[i].Value = value + return &e.Attr[i] + } + } + a := Attr{ + Space: space, + Key: key, + Value: value, + element: parent, + } + e.Attr = append(e.Attr, a) + return &e.Attr[len(e.Attr)-1] +} + +// RemoveAttr removes and returns a copy of the first attribute of the element +// whose key matches the given key. The key may be prefixed by a namespace +// prefix and a colon. If a matching attribute does not exist, nil is +// returned. +func (e *Element) RemoveAttr(key string) *Attr { + space, skey := spaceDecompose(key) + for i, a := range e.Attr { + if space == a.Space && skey == a.Key { + e.Attr = append(e.Attr[0:i], e.Attr[i+1:]...) + return &Attr{ + Space: a.Space, + Key: a.Key, + Value: a.Value, + element: nil, + } + } + } + return nil +} + +// SortAttrs sorts the element's attributes lexicographically by key. +func (e *Element) SortAttrs() { + sort.Sort(byAttr(e.Attr)) +} + +type byAttr []Attr + +func (a byAttr) Len() int { + return len(a) +} + +func (a byAttr) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a byAttr) Less(i, j int) bool { + sp := strings.Compare(a[i].Space, a[j].Space) + if sp == 0 { + return strings.Compare(a[i].Key, a[j].Key) < 0 + } + return sp < 0 +} + +// FullKey returns the attribute a's complete key, including namespace prefix +// if present. +func (a *Attr) FullKey() string { + if a.Space == "" { + return a.Key + } + return a.Space + ":" + a.Key +} + +// Element returns the element containing the attribute. +func (a *Attr) Element() *Element { + return a.element +} + +// NamespaceURI returns the XML namespace URI associated with the attribute. +// If the element is part of the XML default namespace, NamespaceURI returns +// the empty string. +func (a *Attr) NamespaceURI() string { + return a.element.NamespaceURI() +} + +// writeTo serializes the attribute to the writer. +func (a *Attr) writeTo(w *bufio.Writer, s *WriteSettings) { + w.WriteString(a.FullKey()) + w.WriteString(`="`) + var m escapeMode + if s.CanonicalAttrVal { + m = escapeCanonicalAttr + } else { + m = escapeNormal + } + escapeString(w, a.Value, m) + w.WriteByte('"') +} + +// NewText creates a parentless CharData token containing character data. +func NewText(text string) *CharData { + return newCharData(text, 0, nil) +} + +// NewCData creates a parentless XML character CDATA section. +func NewCData(data string) *CharData { + return newCharData(data, cdataFlag, nil) +} + +// NewCharData creates a parentless CharData token containing character data. +// +// Deprecated: NewCharData is deprecated. Instead, use NewText, which does the +// same thing. +func NewCharData(data string) *CharData { + return newCharData(data, 0, nil) +} + +// newCharData creates a character data token and binds it to a parent +// element. If parent is nil, the CharData token remains unbound. +func newCharData(data string, flags charDataFlags, parent *Element) *CharData { + c := &CharData{ + Data: data, + parent: parent, + index: -1, + flags: flags, + } + if parent != nil { + parent.addChild(c) + } + return c +} + +// CreateText creates a CharData token containing character data and adds it +// as a child of element e. +func (e *Element) CreateText(text string) *CharData { + return newCharData(text, 0, e) +} + +// CreateCData creates a CharData token containing a CDATA section and adds it +// as a child of element e. +func (e *Element) CreateCData(data string) *CharData { + return newCharData(data, cdataFlag, e) +} + +// CreateCharData creates a CharData token containing character data and adds +// it as a child of element e. +// +// Deprecated: CreateCharData is deprecated. Instead, use CreateText, which +// does the same thing. +func (e *Element) CreateCharData(data string) *CharData { + return newCharData(data, 0, e) +} + +// dup duplicates the character data. +func (c *CharData) dup(parent *Element) Token { + return &CharData{ + Data: c.Data, + flags: c.flags, + parent: parent, + index: c.index, + } +} + +// IsCData returns true if the character data token is to be encoded as a +// CDATA section. +func (c *CharData) IsCData() bool { + return (c.flags & cdataFlag) != 0 +} + +// IsWhitespace returns true if the character data token was created by one of +// the document Indent methods to contain only whitespace. +func (c *CharData) IsWhitespace() bool { + return (c.flags & whitespaceFlag) != 0 +} + +// Parent returns the character data token's parent element, or nil if it has +// no parent. +func (c *CharData) Parent() *Element { + return c.parent +} + +// Index returns the index of this CharData token within its parent element's +// list of child tokens. If this CharData token has no parent element, the +// index is -1. +func (c *CharData) Index() int { + return c.index +} + +// setParent replaces the character data token's parent. +func (c *CharData) setParent(parent *Element) { + c.parent = parent +} + +// setIndex sets the CharData token's index within its parent element's Child +// slice. +func (c *CharData) setIndex(index int) { + c.index = index +} + +// writeTo serializes character data to the writer. +func (c *CharData) writeTo(w *bufio.Writer, s *WriteSettings) { + if c.IsCData() { + w.WriteString(``) + } else { + var m escapeMode + if s.CanonicalText { + m = escapeCanonicalText + } else { + m = escapeNormal + } + escapeString(w, c.Data, m) + } +} + +// NewComment creates a parentless XML comment. +func NewComment(comment string) *Comment { + return newComment(comment, nil) +} + +// NewComment creates an XML comment and binds it to a parent element. If +// parent is nil, the Comment remains unbound. +func newComment(comment string, parent *Element) *Comment { + c := &Comment{ + Data: comment, + parent: parent, + index: -1, + } + if parent != nil { + parent.addChild(c) + } + return c +} + +// CreateComment creates an XML comment and adds it as a child of element e. +func (e *Element) CreateComment(comment string) *Comment { + return newComment(comment, e) +} + +// dup duplicates the comment. +func (c *Comment) dup(parent *Element) Token { + return &Comment{ + Data: c.Data, + parent: parent, + index: c.index, + } +} + +// Parent returns comment token's parent element, or nil if it has no parent. +func (c *Comment) Parent() *Element { + return c.parent +} + +// Index returns the index of this Comment token within its parent element's +// list of child tokens. If this Comment token has no parent element, the +// index is -1. +func (c *Comment) Index() int { + return c.index +} + +// setParent replaces the comment token's parent. +func (c *Comment) setParent(parent *Element) { + c.parent = parent +} + +// setIndex sets the Comment token's index within its parent element's Child +// slice. +func (c *Comment) setIndex(index int) { + c.index = index +} + +// writeTo serialies the comment to the writer. +func (c *Comment) writeTo(w *bufio.Writer, s *WriteSettings) { + w.WriteString("") +} + +// NewDirective creates a parentless XML directive. +func NewDirective(data string) *Directive { + return newDirective(data, nil) +} + +// newDirective creates an XML directive and binds it to a parent element. If +// parent is nil, the Directive remains unbound. +func newDirective(data string, parent *Element) *Directive { + d := &Directive{ + Data: data, + parent: parent, + index: -1, + } + if parent != nil { + parent.addChild(d) + } + return d +} + +// CreateDirective creates an XML directive and adds it as the last child of +// element e. +func (e *Element) CreateDirective(data string) *Directive { + return newDirective(data, e) +} + +// dup duplicates the directive. +func (d *Directive) dup(parent *Element) Token { + return &Directive{ + Data: d.Data, + parent: parent, + index: d.index, + } +} + +// Parent returns directive token's parent element, or nil if it has no +// parent. +func (d *Directive) Parent() *Element { + return d.parent +} + +// Index returns the index of this Directive token within its parent element's +// list of child tokens. If this Directive token has no parent element, the +// index is -1. +func (d *Directive) Index() int { + return d.index +} + +// setParent replaces the directive token's parent. +func (d *Directive) setParent(parent *Element) { + d.parent = parent +} + +// setIndex sets the Directive token's index within its parent element's Child +// slice. +func (d *Directive) setIndex(index int) { + d.index = index +} + +// writeTo serializes the XML directive to the writer. +func (d *Directive) writeTo(w *bufio.Writer, s *WriteSettings) { + w.WriteString("") +} + +// NewProcInst creates a parentless XML processing instruction. +func NewProcInst(target, inst string) *ProcInst { + return newProcInst(target, inst, nil) +} + +// newProcInst creates an XML processing instruction and binds it to a parent +// element. If parent is nil, the ProcInst remains unbound. +func newProcInst(target, inst string, parent *Element) *ProcInst { + p := &ProcInst{ + Target: target, + Inst: inst, + parent: parent, + index: -1, + } + if parent != nil { + parent.addChild(p) + } + return p +} + +// CreateProcInst creates a processing instruction and adds it as a child of +// element e. +func (e *Element) CreateProcInst(target, inst string) *ProcInst { + return newProcInst(target, inst, e) +} + +// dup duplicates the procinst. +func (p *ProcInst) dup(parent *Element) Token { + return &ProcInst{ + Target: p.Target, + Inst: p.Inst, + parent: parent, + index: p.index, + } +} + +// Parent returns processing instruction token's parent element, or nil if it +// has no parent. +func (p *ProcInst) Parent() *Element { + return p.parent +} + +// Index returns the index of this ProcInst token within its parent element's +// list of child tokens. If this ProcInst token has no parent element, the +// index is -1. +func (p *ProcInst) Index() int { + return p.index +} + +// setParent replaces the processing instruction token's parent. +func (p *ProcInst) setParent(parent *Element) { + p.parent = parent +} + +// setIndex sets the processing instruction token's index within its parent +// element's Child slice. +func (p *ProcInst) setIndex(index int) { + p.index = index +} + +// writeTo serializes the processing instruction to the writer. +func (p *ProcInst) writeTo(w *bufio.Writer, s *WriteSettings) { + w.WriteString("") +} diff --git a/vendor/github.com/beevik/etree/helpers.go b/vendor/github.com/beevik/etree/helpers.go new file mode 100644 index 00000000..825e14e9 --- /dev/null +++ b/vendor/github.com/beevik/etree/helpers.go @@ -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("&") + case '<': + esc = []byte("<") + case '>': + if m == escapeCanonicalAttr { + continue + } + esc = []byte(">") + case '\'': + if m != escapeNormal { + continue + } + esc = []byte("'") + case '"': + if m == escapeCanonicalText { + continue + } + esc = []byte(""") + case '\t': + if m != escapeCanonicalAttr { + continue + } + esc = []byte(" ") + case '\n': + if m != escapeCanonicalAttr { + continue + } + esc = []byte(" ") + case '\r': + if m == escapeNormal { + continue + } + esc = []byte(" ") + 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 +} diff --git a/vendor/github.com/beevik/etree/path.go b/vendor/github.com/beevik/etree/path.go new file mode 100644 index 00000000..82db0ac5 --- /dev/null +++ b/vendor/github.com/beevik/etree/path.go @@ -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] +} diff --git a/vendor/github.com/labstack/echo/v4/echo.go b/vendor/github.com/labstack/echo/v4/echo.go index 39c30d2e..56b2cf8c 100644 --- a/vendor/github.com/labstack/echo/v4/echo.go +++ b/vendor/github.com/labstack/echo/v4/echo.go @@ -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, } ) diff --git a/vendor/github.com/labstack/echo/v4/router.go b/vendor/github.com/labstack/echo/v4/router.go index 85e316a7..8d3a0180 100644 --- a/vendor/github.com/labstack/echo/v4/router.go +++ b/vendor/github.com/labstack/echo/v4/router.go @@ -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 } diff --git a/vendor/github.com/laurent22/ical-go/ical/calendar.go b/vendor/github.com/laurent22/ical-go/ical/calendar.go new file mode 100644 index 00000000..b857f589 --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/calendar.go @@ -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() +} diff --git a/vendor/github.com/laurent22/ical-go/ical/calendar_event.go b/vendor/github.com/laurent22/ical-go/ical/calendar_event.go new file mode 100644 index 00000000..52c95805 --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/calendar_event.go @@ -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 +} diff --git a/vendor/github.com/laurent22/ical-go/ical/lib.go b/vendor/github.com/laurent22/ical-go/ical/lib.go new file mode 100644 index 00000000..4230c097 --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/lib.go @@ -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() +} diff --git a/vendor/github.com/laurent22/ical-go/ical/node.go b/vendor/github.com/laurent22/ical-go/ical/node.go new file mode 100644 index 00000000..252fe523 --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/node.go @@ -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 +} diff --git a/vendor/github.com/laurent22/ical-go/ical/parsers.go b/vendor/github.com/laurent22/ical-go/ical/parsers.go new file mode 100644 index 00000000..5ad3c7bd --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/parsers.go @@ -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 +} diff --git a/vendor/github.com/laurent22/ical-go/ical/properties.go b/vendor/github.com/laurent22/ical-go/ical/properties.go new file mode 100644 index 00000000..44acf9af --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/properties.go @@ -0,0 +1,9 @@ +package ical + +const ( + VCALENDAR = "VCALENDAR" + VEVENT = "VEVENT" + DTSTART = "DTSTART" + DTEND = "DTEND" + DURATION = "DURATION" +) diff --git a/vendor/github.com/laurent22/ical-go/ical/serializers.go b/vendor/github.com/laurent22/ical-go/ical/serializers.go new file mode 100644 index 00000000..12c0070c --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/serializers.go @@ -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)) + } +} diff --git a/vendor/github.com/laurent22/ical-go/ical/todo.go b/vendor/github.com/laurent22/ical-go/ical/todo.go new file mode 100644 index 00000000..48189004 --- /dev/null +++ b/vendor/github.com/laurent22/ical-go/ical/todo.go @@ -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 +// } diff --git a/vendor/github.com/samedi/caldav-go/.gitignore b/vendor/github.com/samedi/caldav-go/.gitignore new file mode 100644 index 00000000..78125861 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/.gitignore @@ -0,0 +1,2 @@ +test-data/ +vendor diff --git a/vendor/github.com/samedi/caldav-go/CHANGELOG.md b/vendor/github.com/samedi/caldav-go/CHANGELOG.md new file mode 100644 index 00000000..c95e230e --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/CHANGELOG.md @@ -0,0 +1,69 @@ +# CHANGELOG + +v3.0.0 +----------- +2017-08-01 Daniel Ferraz + +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 + +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 + +Escape the contents in `` and `` 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 + +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 + +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 diff --git a/vendor/github.com/samedi/caldav-go/LICENSE b/vendor/github.com/samedi/caldav-go/LICENSE new file mode 100644 index 00000000..3213aa0f --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/LICENSE @@ -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. diff --git a/vendor/github.com/samedi/caldav-go/README.md b/vendor/github.com/samedi/caldav-go/README.md new file mode 100644 index 00000000..af4e61ce --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/README.md @@ -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 diff --git a/vendor/github.com/samedi/caldav-go/config.go b/vendor/github.com/samedi/caldav-go/config.go new file mode 100644 index 00000000..52221a20 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/config.go @@ -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 +} \ No newline at end of file diff --git a/vendor/github.com/samedi/caldav-go/data/filters.go b/vendor/github.com/samedi/caldav-go/data/filters.go new file mode 100644 index 00000000..24349f2c --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/data/filters.go @@ -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 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 +} diff --git a/vendor/github.com/samedi/caldav-go/data/resource.go b/vendor/github.com/samedi/caldav-go/data/resource.go new file mode 100644 index 00000000..9f6fdb9e --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/data/resource.go @@ -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() +} diff --git a/vendor/github.com/samedi/caldav-go/data/storage.go b/vendor/github.com/samedi/caldav-go/data/storage.go new file mode 100644 index 00000000..524fa10c --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/data/storage.go @@ -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 +} diff --git a/vendor/github.com/samedi/caldav-go/data/user.go b/vendor/github.com/samedi/caldav-go/data/user.go new file mode 100644 index 00000000..43a25c98 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/data/user.go @@ -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 +} diff --git a/vendor/github.com/samedi/caldav-go/errs/errors.go b/vendor/github.com/samedi/caldav-go/errs/errors.go new file mode 100644 index 00000000..78f6bf8a --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/errs/errors.go @@ -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.") +) diff --git a/vendor/github.com/samedi/caldav-go/files/paths.go b/vendor/github.com/samedi/caldav-go/files/paths.go new file mode 100644 index 00000000..cfcc4e82 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/files/paths.go @@ -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) +} diff --git a/vendor/github.com/samedi/caldav-go/glide.lock b/vendor/github.com/samedi/caldav-go/glide.lock new file mode 100644 index 00000000..36227661 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/glide.lock @@ -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: [] diff --git a/vendor/github.com/samedi/caldav-go/glide.yaml b/vendor/github.com/samedi/caldav-go/glide.yaml new file mode 100644 index 00000000..336e168c --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/glide.yaml @@ -0,0 +1,4 @@ +package: github.com/samedi/caldav-go +import: +- package: github.com/beevik/etree +- package: github.com/laurent22/ical-go diff --git a/vendor/github.com/samedi/caldav-go/global/global.go b/vendor/github.com/samedi/caldav-go/global/global.go new file mode 100644 index 00000000..47eb7260 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/global/global.go @@ -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} \ No newline at end of file diff --git a/vendor/github.com/samedi/caldav-go/go.mod b/vendor/github.com/samedi/caldav-go/go.mod new file mode 100644 index 00000000..42d4b944 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/go.mod @@ -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 +) diff --git a/vendor/github.com/samedi/caldav-go/handler.go b/vendor/github.com/samedi/caldav-go/handler.go new file mode 100644 index 00000000..b4bc63e1 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handler.go @@ -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) +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/builder.go b/vendor/github.com/samedi/caldav-go/handlers/builder.go new file mode 100644 index 00000000..ddd8bd72 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/builder.go @@ -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} + } +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/delete.go b/vendor/github.com/samedi/caldav-go/handlers/delete.go new file mode 100644 index 00000000..c60d28ec --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/delete.go @@ -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, "") +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/get.go b/vendor/github.com/samedi/caldav-go/handlers/get.go new file mode 100644 index 00000000..84acbbaa --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/get.go @@ -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 +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/headers.go b/vendor/github.com/samedi/caldav-go/handlers/headers.go new file mode 100644 index 00000000..f0e552cc --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/headers.go @@ -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) +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/multistatus.go b/vendor/github.com/samedi/caldav-go/handlers/multistatus.go new file mode 100644 index 00000000..6994e0f6 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/multistatus.go @@ -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 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(``, 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(``) + bf.Write(``, ixml.Namespaces()) + + // iterate over event hrefs and build multistatus XML on the fly + for _, response := range ms.Responses { + bf.Write("") + 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("") + bf.Write("") + bf.Write(ixml.StatusTag(http.StatusOK)) + bf.Write("") + bf.Write("") + + continue + } + } + + for status, props := range propstats { + bf.Write("") + bf.Write("") + for _, prop := range props { + bf.Write(ms.propToXML(prop)) + } + bf.Write("") + bf.Write(ixml.StatusTag(status)) + bf.Write("") + } + } else { + // if does not find the resource set 404 + bf.Write(ixml.StatusTag(http.StatusNotFound)) + } + bf.Write("") + } + bf.Write("") + + 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 +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/not_implemented.go b/vendor/github.com/samedi/caldav-go/handlers/not_implemented.go new file mode 100644 index 00000000..839e53f8 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/not_implemented.go @@ -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, "") +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/options.go b/vendor/github.com/samedi/caldav-go/handlers/options.go new file mode 100644 index 00000000..fd3bec0e --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/options.go @@ -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 +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/preconditions.go b/vendor/github.com/samedi/caldav-go/handlers/preconditions.go new file mode 100644 index 00000000..ae8252e9 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/preconditions.go @@ -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 +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/propfind.go b/vendor/github.com/samedi/caldav-go/handlers/propfind.go new file mode 100644 index 00000000..12ecf3fb --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/propfind.go @@ -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()) +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/put.go b/vendor/github.com/samedi/caldav-go/handlers/put.go new file mode 100644 index 00000000..f97a8a28 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/put.go @@ -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, "") +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/report.go b/vendor/github.com/samedi/caldav-go/handlers/report.go new file mode 100644 index 00000000..87238a64 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/report.go @@ -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", 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 +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/response.go b/vendor/github.com/samedi/caldav-go/handlers/response.go new file mode 100644 index 00000000..3e87a4da --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/response.go @@ -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) +} diff --git a/vendor/github.com/samedi/caldav-go/handlers/shared.go b/vendor/github.com/samedi/caldav-go/handlers/shared.go new file mode 100644 index 00000000..9fa9c2a4 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/handlers/shared.go @@ -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) +} diff --git a/vendor/github.com/samedi/caldav-go/ixml/ixml.go b/vendor/github.com/samedi/caldav-go/ixml/ixml.go new file mode 100644 index 00000000..3097cb92 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/ixml/ixml.go @@ -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", ns, name, content, ns, name) + } else { + return fmt.Sprintf("<%s%s/>", ns, name) + } +} + +// HrefTag returns a DAV tag with the given href path. +func HrefTag(href string) (tag string) { + return Tag(HREF_TG, href) +} + +// StatusTag returns a DAV 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() +} diff --git a/vendor/github.com/samedi/caldav-go/lib/components.go b/vendor/github.com/samedi/caldav-go/lib/components.go new file mode 100644 index 00000000..7a386275 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/lib/components.go @@ -0,0 +1,8 @@ +package lib + +const ( + VCALENDAR = "VCALENDAR" + VEVENT = "VEVENT" + VJOURNAL = "VJOURNAL" + VTODO = "VTODO" +) diff --git a/vendor/github.com/samedi/caldav-go/lib/paths.go b/vendor/github.com/samedi/caldav-go/lib/paths.go new file mode 100644 index 00000000..5a016e1f --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/lib/paths.go @@ -0,0 +1,10 @@ +package lib + +import ( + "path/filepath" +) + +func ToSlashPath(path string) string { + cleanPath := filepath.Clean(path) + return filepath.ToSlash(cleanPath) +} diff --git a/vendor/github.com/samedi/caldav-go/lib/strbuff.go b/vendor/github.com/samedi/caldav-go/lib/strbuff.go new file mode 100644 index 00000000..4626fd9d --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/lib/strbuff.go @@ -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() +} diff --git a/vendor/github.com/samedi/caldav-go/test.sh b/vendor/github.com/samedi/caldav-go/test.sh new file mode 100644 index 00000000..09efdc7c --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +go test -race ./... +rm -rf test-data diff --git a/vendor/github.com/samedi/caldav-go/version.go b/vendor/github.com/samedi/caldav-go/version.go new file mode 100644 index 00000000..fee510c5 --- /dev/null +++ b/vendor/github.com/samedi/caldav-go/version.go @@ -0,0 +1,5 @@ +package caldav + +const ( + VERSION = "3.0.0" +) diff --git a/vendor/modules.txt b/vendor/modules.txt index fb54ba20..18c795cc 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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