├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config.go ├── data ├── filters.go ├── filters_test.go ├── resource.go ├── resource_test.go ├── storage.go └── user.go ├── errs └── errors.go ├── files └── paths.go ├── glide.lock ├── glide.yaml ├── global └── global.go ├── handler.go ├── handlers ├── builder.go ├── delete.go ├── get.go ├── headers.go ├── multistatus.go ├── multistatus_test.go ├── not_implemented.go ├── options.go ├── preconditions.go ├── propfind.go ├── put.go ├── report.go ├── report_test.go ├── response.go └── shared.go ├── integration_test.go ├── ixml └── ixml.go ├── lib ├── components.go ├── paths.go └── strbuff.go ├── test.sh ├── test ├── assertions.go ├── errors.go └── resources.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | test-data/ 2 | vendor 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | v3.0.0 4 | ----------- 5 | 2017-08-01 Daniel Ferraz 6 | 7 | Main change: 8 | 9 | Add two ways to get resources from the storage: shallow or not. 10 | 11 | `data.GetShallowResource`: means that, if it's a collection resource, it will not include its child VEVENTs in the ICS data. 12 | This is used throughout the palces where children don't matter. 13 | 14 | `data.GetResource`: means that the child VEVENTs will be included in the returned ICS content data for collection resources. 15 | This is used when sending a GET request to fetch a specific resource and expecting its full ICS data in response. 16 | 17 | Other changes: 18 | 19 | * Removed the need to pass the useless `writer http.ResponseWriter` parameter when calling the `caldav.HandleRequest` function. 20 | * 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. 21 | 22 | 23 | v2.0.0 24 | ----------- 25 | 2017-05-10 Daniel Ferraz 26 | 27 | All commits squashed and LICENSE updated to release as OSS in github. 28 | Feature-wise it remains the same. 29 | 30 | 31 | v1.0.1 32 | ----------- 33 | 2017-01-25 Daniel Ferraz 34 | 35 | Escape the contents in `` and `` in the `multistatus` XML responses. Fixing possible bugs 36 | related to having special characters (e.g. &) in the XML multistatus responses that would possibly break the encoding. 37 | 38 | v1.0.0 39 | ----------- 40 | 2017-01-18 Daniel Ferraz 41 | 42 | Main feature: 43 | 44 | * 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. 45 | 46 | Other changes: 47 | 48 | * Added the `handlers.Response` to allow clients of the lib to interact with the generated response before being written/sent back to the client. 49 | * 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. 50 | * 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`. 51 | * Remove useless `IsResourcePresent` from the storage interface. 52 | 53 | 54 | v0.1.0 55 | ----------- 56 | 2016-09-23 Daniel Ferraz 57 | 58 | This version implements: 59 | 60 | * Allow: "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, REPORT" 61 | * DAV: "1, 3, calendar-access" 62 | * Also only handles the following components: `VCALENDAR`, `VEVENT` 63 | 64 | Currently unsupported: 65 | 66 | * Components `VTODO`, `VJOURNAL`, `VFREEBUSY` 67 | * `VEVENT` recurrences 68 | * Resource locking 69 | * User authentication 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 samedi GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go CalDAV 2 | 3 | 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. 4 | 5 | ### How to install 6 | 7 | ``` 8 | go get github.com/samedi/caldav-go 9 | ``` 10 | 11 | ### Dependencies 12 | 13 | For dependency management, `glide` is used. 14 | 15 | ```bash 16 | # install glide (once!) 17 | curl https://glide.sh/get | sh 18 | 19 | # install dependencies 20 | glide install 21 | ``` 22 | 23 | ### How to use it 24 | 25 | The easiest way to quickly implement a CalDAV server is by using the lib's request handler. Example: 26 | 27 | ```go 28 | package mycaldav 29 | 30 | import ( 31 | "net/http" 32 | "github.com/samedi/caldav-go" 33 | ) 34 | 35 | func runServer() { 36 | http.HandleFunc(PATH, caldav.RequestHandler) 37 | http.ListenAndServe(PORT, nil) 38 | } 39 | ``` 40 | 41 | 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. 42 | 43 | In case you want more flexibility to handle the requests, e.g., if you want to access the generated response before being sent back to the client, you can do something like this: 44 | 45 | ```go 46 | package mycaldav 47 | 48 | import ( 49 | "net/http" 50 | "github.com/samedi/caldav-go" 51 | ) 52 | 53 | func runServer() { 54 | http.HandleFunc(PATH, myHandler) 55 | http.ListenAndServe(PORT, nil) 56 | } 57 | 58 | func myHandler(writer http.ResponseWriter, request *http.Request) { 59 | response := caldav.HandleRequest(request) 60 | // ... do something with the response object before writing it back to the client ... 61 | response.Write(writer) 62 | } 63 | ``` 64 | 65 | ### Configuration 66 | 67 | You can configure the lib in a number of ways to fit your needs and your server implementation. 68 | 69 | ##### 1) Storage 70 | 71 | The storage is where the CalDAV resources are stored. Say you fetch your resources from your REST API in the cloud. You need to tell `caldav-go` that: 72 | 73 | ```go 74 | stg := new(MyApiStorage) 75 | caldav.SetupStorage(stg) 76 | ``` 77 | 78 | All the CRUD operations on resources will then be forwarded to your API storage implementation. 79 | 80 | The default storage used (if none is provided) is the `data.FileStorage`, which deals with resources as files in the File System. 81 | 82 | Take a look at [Storage & Resource](#storage--resources) to know more how to have your own storage implementation. 83 | 84 | ##### 2) Supported Components 85 | 86 | The current CalDAV components supported by this lib are `VCALENDAR` and `VEVENT`. If your server implementation supports more components, you can set this up like so: 87 | 88 | ```go 89 | caldav.SetupSupportedComponents([]string{'VCALENDAR', 'VEVENT', 'VTODO'}) 90 | ``` 91 | 92 | This data is used internally and returned in some client responses, e.g, in multistatus responses under the `` tag. 93 | 94 | ##### 3) User 95 | 96 | You can set 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, e.g, in the multistatus responses under the `` tag. 97 | 98 | ```go 99 | caldav.SetupUser('john') 100 | ``` 101 | 102 | It's not mandatory to set this up. Only if it makes sense to your server implementation. 103 | 104 | ### Storage & Resources 105 | 106 | The storage is where the CalDAV resources are stored. To interact with that, the `caldav-go` needs 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. 107 | 108 | For example, we could use the following dummy read-only storage implementation, which returns dummy hard-coded resources: 109 | 110 | ```go 111 | type DummyStorage struct{ 112 | resources map[string]string{ 113 | "/foo": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160914T170000\nEND:VEVENT\nEND:VCALENDAR`, 114 | "/bar": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160915T180000\nEND:VEVENT\nEND:VCALENDAR`, 115 | "/baz": `BEGING:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20160916T190000\nEND:VEVENT\nEND:VCALENDAR`, 116 | } 117 | } 118 | 119 | func (d *DummyStorage) GetResource(rpath string) (*Resource, bool, error) { 120 | result := []Resource{} 121 | resContent := d.resources[rpath] 122 | 123 | if resContent != "" { 124 | resource = NewResource(rpath, DummyResourceAdapter{rpath, resContent}) 125 | return &resource, true, nil 126 | } 127 | 128 | return nil, false, nil 129 | } 130 | 131 | func (d *DummyStorage) GetResourcesByList(rpaths []string) ([]Resource, error) { 132 | result := []Resource{} 133 | 134 | for _, rpath := range rpaths { 135 | resource, found, _ := d.GetResource(rpath) 136 | if found { 137 | result = append(result, resource) 138 | } 139 | } 140 | 141 | return result, nil 142 | } 143 | 144 | func (d *DummyStorage) GetResources(rpath string, withChildren bool) ([]Resource, error) { 145 | // ... 146 | } 147 | 148 | func (d *DummyStorage) GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error) { 149 | // ... 150 | } 151 | 152 | func (d *DummyStorage) GetShallowResource(rpath string) (*Resource, bool, error) { 153 | // ... 154 | } 155 | 156 | func (d *DummyStorage) CreateResource(rpath, content string) (*Resource, error) { 157 | return nil, errors.New("creating resources are not supported") 158 | } 159 | 160 | func (d *DummyStorage) UpdateResource(rpath, content string) (*Resource, error) { 161 | return nil, errors.New("updating resources are not supported") 162 | } 163 | 164 | func (d *DummyStorage) DeleteResource(rpath string) error { 165 | return nil, errors.New("deleting resources are not supported") 166 | } 167 | ``` 168 | 169 | In this storage, we just find the hard-coded resource in the map given its path `rpath`. The raw resources are returned as `Resource` objects. If you noticed on the `GetResource` function, when we create this object, we need to pass a resource adapter. 170 | 171 | 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. 172 | 173 | In our example here, we could say that the adapter for this case would be: 174 | 175 | ```go 176 | type DummyResourceAdapter struct { 177 | resourcePath string 178 | resourceData string 179 | } 180 | 181 | func (a *DummyResourceAdapter) IsCollection() bool { 182 | return false 183 | } 184 | 185 | func (a *DummyResourceAdapter) GetContent() string { 186 | return a.resourceData 187 | } 188 | 189 | func (a *DummyResourceAdapter) GetContentSize() int64 { 190 | return len(a.GetContent()) 191 | } 192 | 193 | func (a *DummyResourceAdapter) CalculateEtag() string { 194 | return hashify(a.GetContent()) 195 | } 196 | 197 | func (a *DummyResourceAdapter) GetModTime() time.Time { 198 | return time.Now() 199 | } 200 | ``` 201 | 202 | As a final step, with your own resource storage implementation in place, you need to tell `caldav-go` to use it through the [storage configuration](#configuration). 203 | 204 | ##### Resource Types 205 | 206 | 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. 207 | 208 | ### Features 209 | 210 | Please check the **CHANGELOG** to see specific features that are currently implemented. 211 | 212 | ### Contributing and testing 213 | 214 | Everyone is welcome to contribute. Please raise an issue or pull request accordingly. 215 | 216 | To run the tests: 217 | 218 | ``` 219 | ./test.sh 220 | ``` 221 | 222 | ### License 223 | 224 | MIT License. 225 | 226 | [RFC4791]: https://tools.ietf.org/html/rfc4791 227 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package caldav 2 | 3 | import ( 4 | "github.com/samedi/caldav-go/data" 5 | "github.com/samedi/caldav-go/global" 6 | ) 7 | 8 | // SetupStorage sets the storage to be used by the server. The storage is where the resources data will be fetched from. 9 | // You can provide a custom storage for your own purposes (which might be looking for data in the cloud, DB, etc). 10 | // Just make sure it implements the `data.Storage` interface. 11 | func SetupStorage(stg data.Storage) { 12 | global.Storage = stg 13 | } 14 | 15 | // SetupUser sets the current user which is currently interacting with the calendar. 16 | // It is used, for example, in some of the CALDAV responses, when rendering the path where to find the user's resources. 17 | func SetupUser(username string) { 18 | global.User = &data.CalUser{Name: username} 19 | } 20 | 21 | // SetupSupportedComponents sets all components which are supported by this storage implementation. 22 | func SetupSupportedComponents(components []string) { 23 | global.SupportedComponents = components 24 | } 25 | -------------------------------------------------------------------------------- /data/filters.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "errors" 5 | "github.com/beevik/etree" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/samedi/caldav-go/lib" 11 | ) 12 | 13 | const ( 14 | TAG_FILTER = "filter" 15 | TAG_COMP_FILTER = "comp-filter" 16 | TAG_PROP_FILTER = "prop-filter" 17 | TAG_PARAM_FILTER = "param-filter" 18 | TAG_TIME_RANGE = "time-range" 19 | TAG_TEXT_MATCH = "text-match" 20 | TAG_IS_NOT_DEFINED = "is-not-defined" 21 | 22 | // From the RFC, the time range `start` and `end` attributes MUST be in UTC and in this specific format 23 | FILTER_TIME_FORMAT = "20060102T150405Z" 24 | ) 25 | 26 | // ResourceFilter represents filters to filter out resources. 27 | // Filters are basically a set of rules used to retrieve a range of resources. 28 | // It is used primarily on REPORT requests and is described in details in RFC4791#7.8. 29 | type ResourceFilter struct { 30 | name string 31 | text string 32 | attrs map[string]string 33 | children []ResourceFilter // collection of child filters. 34 | etreeElem *etree.Element // holds the parsed XML node/tag as an `etree` element. 35 | } 36 | 37 | // ParseResourceFilters initializes a new `ResourceFilter` object from a snippet of XML string. 38 | func ParseResourceFilters(xml string) (*ResourceFilter, error) { 39 | doc := etree.NewDocument() 40 | if err := doc.ReadFromString(xml); err != nil { 41 | log.Printf("ERROR: Could not parse filter from XML string. XML:\n%s", xml) 42 | return new(ResourceFilter), err 43 | } 44 | 45 | // Right now we're searching for a tag to initialize the filter struct from it. 46 | // It SHOULD be a valid XML CALDAV:filter tag (RFC4791#9.7). We're not checking namespaces yet. 47 | // TODO: check for XML namespaces and restrict it to accept only CALDAV:filter tag. 48 | elem := doc.FindElement("//" + TAG_FILTER) 49 | if elem == nil { 50 | log.Printf("WARNING: The filter XML should contain a <%s> element. XML:\n%s", TAG_FILTER, xml) 51 | return new(ResourceFilter), errors.New("invalid XML filter") 52 | } 53 | 54 | filter := newFilterFromEtreeElem(elem) 55 | return &filter, nil 56 | } 57 | 58 | func newFilterFromEtreeElem(elem *etree.Element) ResourceFilter { 59 | // init filter from etree element 60 | filter := ResourceFilter{ 61 | name: elem.Tag, 62 | text: strings.TrimSpace(elem.Text()), 63 | etreeElem: elem, 64 | attrs: make(map[string]string), 65 | } 66 | 67 | // set attributes 68 | for _, attr := range elem.Attr { 69 | filter.attrs[attr.Key] = attr.Value 70 | } 71 | 72 | return filter 73 | } 74 | 75 | // Attr searches an attribute by its name in the list of filter attributes and returns it. 76 | func (f *ResourceFilter) Attr(attrName string) string { 77 | return f.attrs[attrName] 78 | } 79 | 80 | // TimeAttr searches and returns a filter attribute as a `time.Time` object. 81 | func (f *ResourceFilter) TimeAttr(attrName string) *time.Time { 82 | 83 | t, err := time.Parse(FILTER_TIME_FORMAT, f.attrs[attrName]) 84 | if err != nil { 85 | return nil 86 | } 87 | 88 | return &t 89 | } 90 | 91 | // GetTimeRangeFilter checks if the current filter has a child "time-range" filter and 92 | // returns it (wrapped in a `ResourceFilter` type). It returns nil if the current filter does 93 | // not contain any "time-range" filter. 94 | func (f *ResourceFilter) GetTimeRangeFilter() *ResourceFilter { 95 | return f.findChild(TAG_TIME_RANGE, true) 96 | } 97 | 98 | // Match returns whether a provided resource matches the filters. 99 | func (f *ResourceFilter) Match(target ResourceInterface) bool { 100 | if f.name == TAG_FILTER { 101 | return f.rootFilterMatch(target) 102 | } 103 | 104 | return false 105 | } 106 | 107 | func (f *ResourceFilter) rootFilterMatch(target ResourceInterface) bool { 108 | if f.isEmpty() { 109 | return false 110 | } 111 | 112 | return f.rootChildrenMatch(target) 113 | } 114 | 115 | // checks if all the root's child filters match the target resource 116 | func (f *ResourceFilter) rootChildrenMatch(target ResourceInterface) bool { 117 | scope := []string{} 118 | 119 | for _, child := range f.getChildren() { 120 | // root filters only accept comp filters as children 121 | if child.name != TAG_COMP_FILTER || !child.compMatch(target, scope) { 122 | return false 123 | } 124 | } 125 | 126 | return true 127 | } 128 | 129 | // See RFC4791-9.7.1. 130 | func (f *ResourceFilter) compMatch(target ResourceInterface, scope []string) bool { 131 | targetComp := target.ComponentName() 132 | compName := f.attrs["name"] 133 | 134 | if f.isEmpty() { 135 | // Point #1 of RFC4791#9.7.1 136 | return compName == targetComp 137 | } else if f.contains(TAG_IS_NOT_DEFINED) { 138 | // Point #2 of RFC4791#9.7.1 139 | return compName != targetComp 140 | } else { 141 | // check each child of the current filter if they all match. 142 | childrenScope := append(scope, compName) 143 | return f.compChildrenMatch(target, childrenScope) 144 | } 145 | } 146 | 147 | // checks if all the comp's child filters match the target resource 148 | func (f *ResourceFilter) compChildrenMatch(target ResourceInterface, scope []string) bool { 149 | for _, child := range f.getChildren() { 150 | var match bool 151 | 152 | switch child.name { 153 | case TAG_TIME_RANGE: 154 | // Point #3 of RFC4791#9.7.1 155 | match = child.timeRangeMatch(target) 156 | case TAG_PROP_FILTER: 157 | // Point #4 of RFC4791#9.7.1 158 | match = child.propMatch(target, scope) 159 | case TAG_COMP_FILTER: 160 | // Point #4 of RFC4791#9.7.1 161 | match = child.compMatch(target, scope) 162 | } 163 | 164 | if !match { 165 | return false 166 | } 167 | } 168 | 169 | return true 170 | } 171 | 172 | // See RFC4791-9.9 173 | func (f *ResourceFilter) timeRangeMatch(target ResourceInterface) bool { 174 | startAttr := f.attrs["start"] 175 | endAttr := f.attrs["end"] 176 | 177 | // at least one of the two MUST be present 178 | if startAttr == "" && endAttr == "" { 179 | // if both of them are missing, return false 180 | return false 181 | } else if startAttr == "" { 182 | // if missing only the `start`, set it open ended to the left 183 | startAttr = "00010101T000000Z" 184 | } else if endAttr == "" { 185 | // if missing only the `end`, set it open ended to the right 186 | endAttr = "99991231T235959Z" 187 | } 188 | 189 | // The logic below is only applicable for VEVENT components. So 190 | // we return false if the resource is not a VEVENT component. 191 | if target.ComponentName() != lib.VEVENT { 192 | return false 193 | } 194 | 195 | rangeStart, err := time.Parse(FILTER_TIME_FORMAT, startAttr) 196 | if err != nil { 197 | log.Printf("ERROR: Could not parse start time in time-range filter.\nError: %s.\nStart attr: %s", err, startAttr) 198 | return false 199 | } 200 | 201 | rangeEnd, err := time.Parse(FILTER_TIME_FORMAT, endAttr) 202 | if err != nil { 203 | log.Printf("ERROR: Could not parse end time in time-range filter.\nError: %s.\nEnd attr: %s", err, endAttr) 204 | return false 205 | } 206 | 207 | // the following logic is inferred from the rules table for VEVENT components, 208 | // described in RFC4791-9.9. 209 | overlapRange := func(dtStart, dtEnd, rangeStart, rangeEnd time.Time) bool { 210 | if dtStart.Equal(dtEnd) { 211 | // Lines 3 and 4 of the table deal when the DTSTART and DTEND dates are equals. 212 | // In this case we use the rule: (start <= DTSTART && end > DTSTART) 213 | return (rangeStart.Before(dtStart) || rangeStart.Equal(dtStart)) && rangeEnd.After(dtStart) 214 | } else { 215 | // Lines 1, 2 and 6 of the table deal when the DTSTART and DTEND dates are different. 216 | // In this case we use the rule: (start < DTEND && end > DTSTART) 217 | return rangeStart.Before(dtEnd) && rangeEnd.After(dtStart) 218 | } 219 | } 220 | 221 | // first we check each of the target recurrences (if any). 222 | for _, recurrence := range target.Recurrences() { 223 | // if any of them overlap the filter range, we return true right away 224 | if overlapRange(recurrence.StartTime, recurrence.EndTime, rangeStart, rangeEnd) { 225 | return true 226 | } 227 | } 228 | 229 | // if none of the recurrences match, we just return if the actual 230 | // resource's `start` and `end` times match the filter range 231 | return overlapRange(target.StartTimeUTC(), target.EndTimeUTC(), rangeStart, rangeEnd) 232 | } 233 | 234 | // See RFC4791-9.7.2. 235 | func (f *ResourceFilter) propMatch(target ResourceInterface, scope []string) bool { 236 | propName := f.attrs["name"] 237 | propPath := append(scope, propName) 238 | 239 | if f.isEmpty() { 240 | // Point #1 of RFC4791#9.7.2 241 | return target.HasProperty(propPath...) 242 | } else if f.contains(TAG_IS_NOT_DEFINED) { 243 | // Point #2 of RFC4791#9.7.2 244 | return !target.HasProperty(propPath...) 245 | } else { 246 | // check each child of the current filter if they all match. 247 | return f.propChildrenMatch(target, propPath) 248 | } 249 | } 250 | 251 | // checks if all the prop's child filters match the target resource 252 | func (f *ResourceFilter) propChildrenMatch(target ResourceInterface, propPath []string) bool { 253 | for _, child := range f.getChildren() { 254 | var match bool 255 | 256 | switch child.name { 257 | case TAG_TIME_RANGE: 258 | // Point #3 of RFC4791#9.7.2 259 | // TODO: this point is not very clear on how to match time range against properties. 260 | // So we're returning `false` in the meantime. 261 | match = false 262 | case TAG_TEXT_MATCH: 263 | // Point #4 of RFC4791#9.7.2 264 | propText := target.GetPropertyValue(propPath...) 265 | match = child.textMatch(propText) 266 | case TAG_PARAM_FILTER: 267 | // Point #4 of RFC4791#9.7.2 268 | match = child.paramMatch(target, propPath) 269 | } 270 | 271 | if !match { 272 | return false 273 | } 274 | } 275 | 276 | return true 277 | } 278 | 279 | // See RFC4791-9.7.3 280 | func (f *ResourceFilter) paramMatch(target ResourceInterface, parentPropPath []string) bool { 281 | paramName := f.attrs["name"] 282 | paramPath := append(parentPropPath, paramName) 283 | 284 | if f.isEmpty() { 285 | // Point #1 of RFC4791#9.7.3 286 | return target.HasPropertyParam(paramPath...) 287 | } else if f.contains(TAG_IS_NOT_DEFINED) { 288 | // Point #2 of RFC4791#9.7.3 289 | return !target.HasPropertyParam(paramPath...) 290 | } else { 291 | child := f.getChildren()[0] 292 | // param filters can also have (only-one) nested text-match filter 293 | if child.name == TAG_TEXT_MATCH { 294 | paramValue := target.GetPropertyParamValue(paramPath...) 295 | return child.textMatch(paramValue) 296 | } 297 | } 298 | 299 | return false 300 | } 301 | 302 | // See RFC4791-9.7.5 303 | func (f *ResourceFilter) textMatch(targetText string) bool { 304 | // TODO: collations are not being considered/supported yet. 305 | // Texts are lowered to be case-insensitive, almost as the "i;ascii-casemap" value. 306 | 307 | targetText = strings.ToLower(targetText) 308 | expectedSubstr := strings.ToLower(f.text) 309 | 310 | match := strings.Contains(targetText, expectedSubstr) 311 | 312 | if f.attrs["negate-condition"] == "yes" { 313 | return !match 314 | } 315 | 316 | return match 317 | } 318 | 319 | func (f *ResourceFilter) isEmpty() bool { 320 | return len(f.getChildren()) == 0 && f.text == "" 321 | } 322 | 323 | func (f *ResourceFilter) contains(filterName string) bool { 324 | if f.findChild(filterName, false) != nil { 325 | return true 326 | } 327 | 328 | return false 329 | } 330 | 331 | func (f *ResourceFilter) findChild(filterName string, dig bool) *ResourceFilter { 332 | for _, child := range f.getChildren() { 333 | if child.name == filterName { 334 | return &child 335 | } 336 | 337 | if !dig { 338 | continue 339 | } 340 | 341 | dugChild := child.findChild(filterName, true) 342 | 343 | if dugChild != nil { 344 | return dugChild 345 | } 346 | } 347 | 348 | return nil 349 | } 350 | 351 | // lazy evaluation of the child filters 352 | func (f *ResourceFilter) getChildren() []ResourceFilter { 353 | if f.children == nil { 354 | f.children = []ResourceFilter{} 355 | 356 | for _, childElem := range f.etreeElem.ChildElements() { 357 | childFilter := newFilterFromEtreeElem(childElem) 358 | f.children = append(f.children, childFilter) 359 | } 360 | } 361 | 362 | return f.children 363 | } 364 | -------------------------------------------------------------------------------- /data/filters_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestParseFilter(t *testing.T) { 10 | filter, err := ParseResourceFilters(``) 11 | if err != nil { 12 | t.Error("Parsing filter from a valid XML returned an error:", err) 13 | } 14 | 15 | if filter == nil { 16 | t.Error("Parsing filter from a valid XML returned a nil filter") 17 | } 18 | 19 | invalidXMLs := []string{ 20 | ``, 21 | ``, 22 | } 23 | 24 | for _, invalidXML := range invalidXMLs { 25 | filter, err = ParseResourceFilters(invalidXML) 26 | if err == nil { 27 | t.Error("Parsing filter from an invalid XML should return an error") 28 | } 29 | } 30 | } 31 | 32 | func TestMatch1(t *testing.T) { 33 | filterXML := ` 34 | 35 | ` 36 | 37 | assertFilterDoesNotMatch(filterXML, FakeResource{}, t) 38 | } 39 | 40 | func TestMatch2(t *testing.T) { 41 | filterXML := ` 42 | 43 | 44 | 45 | ` 46 | 47 | // matches if the resource's component type is "VCALENDAR" 48 | assertFilterMatch(filterXML, FakeResource{comp: "VCALENDAR"}, t) 49 | assertFilterDoesNotMatch(filterXML, FakeResource{comp: "VEVENT"}, t) 50 | } 51 | 52 | func TestMatch3(t *testing.T) { 53 | filterXML := ` 54 | 55 | 56 | 57 | 58 | 59 | ` 60 | 61 | // matches if the resource's component type is "VEVENT" 62 | assertFilterMatch(filterXML, FakeResource{comp: "VEVENT"}, t) 63 | assertFilterDoesNotMatch(filterXML, FakeResource{comp: "VCALENDAR"}, t) 64 | } 65 | 66 | func TestMatch4(t *testing.T) { 67 | filterXML := ` 68 | 69 | 70 | 71 | 72 | 73 | 74 | ` 75 | 76 | // the `is-not-defined` inside the `comp-filter` works as a boolean `not`, 77 | // therefore matching if the resource's component type is NOT "VEVENT" 78 | assertFilterMatch(filterXML, FakeResource{comp: "VCALENDAR"}, t) 79 | assertFilterDoesNotMatch(filterXML, FakeResource{comp: "VEVENT"}, t) 80 | } 81 | 82 | func TestMatch5(t *testing.T) { 83 | filterXML := ` 84 | 85 | 86 | 87 | 88 | 89 | 90 | ` 91 | 92 | // A `time-range` without `start` and `end` properties is not valid. 93 | assertFilterDoesNotMatch(filterXML, FakeResource{}, t) 94 | } 95 | 96 | func TestMatch6(t *testing.T) { 97 | filterXML := ` 98 | 99 | 100 | 101 | 102 | 103 | 104 | ` 105 | 106 | // set of tests when the resource's `start` and `end` property are different 107 | 108 | // out of the interval - doesnt match! 109 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160913T000000Z", end: "20160914T000000Z"}, t) 110 | // resource's `end` is inside the interval - match! 111 | assertFilterMatch(filterXML, FakeResource{start: "20160913T000000Z", end: "20160915T000000Z"}, t) 112 | // resource's `start` is inside the interval - match! 113 | assertFilterMatch(filterXML, FakeResource{start: "20160915T000000Z", end: "20160917T000000Z"}, t) 114 | // out of the interval - doesnt match! 115 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160916T000000Z", end: "20160917T000000Z"}, t) 116 | 117 | // set of tests when the resource's `start` and `end` property are equal 118 | 119 | // out of the interval - doesnt match! 120 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160913T000000Z", end: "20160913T000000Z"}, t) 121 | // in the interval - match! 122 | assertFilterMatch(filterXML, FakeResource{start: "20160914T000000Z", end: "20160914T000000Z"}, t) 123 | // in the interval - match! 124 | assertFilterMatch(filterXML, FakeResource{start: "20160915T000000Z", end: "20160915T000000Z"}, t) 125 | // out of the interval - doesnt match! 126 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160916T000000Z", end: "20160916T000000Z"}, t) 127 | 128 | // set of tests to check for the resource's recurrences. If any of the recurrences overlaps the interval, 129 | // it should match the filter. 130 | 131 | res := FakeResource{start: "20140913T000000Z", end: "20140915T000000Z"} 132 | // out of the interval - doesnt match! 133 | assertFilterDoesNotMatch(filterXML, res, t) 134 | 135 | res.addRecurrence("20150913T000000Z", "20150915T000000Z") 136 | // recurrence is out of the interval - still doesnt match! 137 | assertFilterDoesNotMatch(filterXML, res, t) 138 | 139 | res.addRecurrence("20160913T000000Z", "20160915T000000Z") 140 | // recurrence is in the interval - match! 141 | assertFilterMatch(filterXML, res, t) 142 | } 143 | 144 | func TestMatch7(t *testing.T) { 145 | // when the `end` attribute is not defined, it is open ended (to infinity) 146 | filterXML := ` 147 | 148 | 149 | 150 | 151 | 152 | 153 | ` 154 | 155 | // out of the interval - doesnt match! 156 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160912T000000Z", end: "20160913T000000Z"}, t) 157 | // in the interval - match! 158 | assertFilterMatch(filterXML, FakeResource{start: "20160912T000000Z", end: "20170913T000000Z"}, t) 159 | } 160 | 161 | func TestMatch8(t *testing.T) { 162 | // when the `start` attribute is not defined, it is open ended (to infinity) 163 | filterXML := ` 164 | 165 | 166 | 167 | 168 | 169 | 170 | ` 171 | 172 | // out of the interval - doesnt match! 173 | assertFilterDoesNotMatch(filterXML, FakeResource{start: "20160917T000000Z", end: "20160918T000000Z"}, t) 174 | // in the interval - match! 175 | assertFilterMatch(filterXML, FakeResource{start: "20150917T000000Z", end: "20160918T000000Z"}, t) 176 | } 177 | 178 | func TestMatch9(t *testing.T) { 179 | filterXML := ` 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | ` 188 | 189 | res := FakeResource{} 190 | // does not contain the property UID - doesnt match! 191 | assertFilterDoesNotMatch(filterXML, res, t) 192 | // now contains the property UID - match! 193 | res.addProperty("VCALENDAR:VEVENT:UID", "") 194 | assertFilterMatch(filterXML, res, t) 195 | } 196 | 197 | func TestMatch10(t *testing.T) { 198 | filterXML := ` 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | ` 208 | 209 | // the `is-not-defined` works as a boolean `not`, 210 | // therefore matching if the resource DOES NOT have the property UID 211 | res := FakeResource{} 212 | assertFilterMatch(filterXML, res, t) 213 | res.addProperty("VCALENDAR:VEVENT:UID", "") 214 | assertFilterDoesNotMatch(filterXML, res, t) 215 | } 216 | 217 | func TestMatch11(t *testing.T) { 218 | filterXML := ` 219 | 220 | 221 | 222 | 223 | 224 | @ExAmplE.coM 225 | 226 | 227 | 228 | 229 | ` 230 | 231 | res := FakeResource{} 232 | // the resource does not have the property - doesnt match! 233 | assertFilterDoesNotMatch(filterXML, res, t) 234 | // the property content does not have the substring - doesnt match! 235 | res.addProperty("VCALENDAR:VEVENT:UID", "DC6C50A017428C5216A2F1CD@foobar.com") 236 | assertFilterDoesNotMatch(filterXML, res, t) 237 | // the property content has the substring - match! 238 | res.addProperty("VCALENDAR:VEVENT:UID", "DC6C50A017428C5216A2F1CD@example.com") 239 | assertFilterMatch(filterXML, res, t) 240 | 241 | // with `negate-condition` as "no" 242 | filterXML = ` 243 | 244 | 245 | 246 | 247 | 248 | @ExAmplE.coM 249 | 250 | 251 | 252 | 253 | ` 254 | // the property content has the substring - match! 255 | assertFilterMatch(filterXML, res, t) 256 | 257 | // with `negate-condition` as "yes" 258 | filterXML = ` 259 | 260 | 261 | 262 | 263 | 264 | @ExAmplE.coM 265 | 266 | 267 | 268 | 269 | ` 270 | // the property content has the substring - doesnt match! 271 | assertFilterDoesNotMatch(filterXML, res, t) 272 | } 273 | 274 | func TestMatch12(t *testing.T) { 275 | filterXML := ` 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | ` 286 | 287 | res := FakeResource{} 288 | // does not contain the property param ATTENDEE:PARTSTAT - doesnt match! 289 | assertFilterDoesNotMatch(filterXML, res, t) 290 | // now contains the property param ATTENDEE:PARTSTAT - match! 291 | res.addPropertyParam("VCALENDAR:VEVENT:ATTENDEE:PARTSTAT", "") 292 | assertFilterMatch(filterXML, res, t) 293 | } 294 | 295 | func TestMatch13(t *testing.T) { 296 | filterXML := ` 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | ` 308 | 309 | // the `is-not-defined` works as a boolean `not`, 310 | // therefore matching if the resource DOES NOT have the property param ATTENDEE:PARTSTAT 311 | res := FakeResource{} 312 | assertFilterMatch(filterXML, res, t) 313 | res.addPropertyParam("VCALENDAR:VEVENT:ATTENDEE:PARTSTAT", "") 314 | assertFilterDoesNotMatch(filterXML, res, t) 315 | } 316 | 317 | func TestMatch14(t *testing.T) { 318 | filterXML := ` 319 | 320 | 321 | 322 | 323 | 324 | NEEDS ACTION 325 | 326 | 327 | 328 | 329 | ` 330 | 331 | res := FakeResource{} 332 | // the resource does not have the property param - doesnt match! 333 | assertFilterDoesNotMatch(filterXML, res, t) 334 | // the property param content does not have the substring - doesnt match! 335 | res.addPropertyParam("VCALENDAR:VEVENT:ATTENDEE:PARTSTAT", "FOO BAR") 336 | assertFilterDoesNotMatch(filterXML, res, t) 337 | // the property param content has the substring - match! 338 | res.addPropertyParam("VCALENDAR:VEVENT:ATTENDEE:PARTSTAT", "FOO BAR NEEDS ACTION") 339 | assertFilterMatch(filterXML, res, t) 340 | } 341 | 342 | func TestGetTimeRangeFilter(t *testing.T) { 343 | // First testing when the filters contain a time-range filter 344 | filterXML := ` 345 | 346 | 347 | 348 | 349 | 350 | 351 | ` 352 | filters, err := ParseResourceFilters(filterXML) 353 | panicerr(err) 354 | 355 | timeRange := filters.GetTimeRangeFilter() 356 | 357 | if timeRange == nil { 358 | t.Error("should have returned the time range filter, not nil.") 359 | return 360 | } 361 | 362 | if timeRange.Attr("start") != "20150916T000000Z" || timeRange.Attr("end") != "20160916T000000Z" { 363 | t.Error("should have returned the correct time range filter with the correct attributes") 364 | } 365 | 366 | // Now testing when the filters DO NOT contain a time-range filter 367 | filterXML = ` 368 | 369 | 370 | 371 | 372 | 373 | ` 374 | 375 | filters, err = ParseResourceFilters(filterXML) 376 | panicerr(err) 377 | 378 | timeRange = filters.GetTimeRangeFilter() 379 | 380 | if timeRange != nil { 381 | t.Error("should not have returned time range filter") 382 | } 383 | } 384 | 385 | func TestTimeAttr(t *testing.T) { 386 | // test with valid times 387 | f := ResourceFilter{ 388 | attrs: map[string]string{ 389 | "start": "20150916T000000Z", 390 | "end": "20150916T000000Z", 391 | }, 392 | } 393 | 394 | start := f.TimeAttr("start") 395 | end := f.TimeAttr("end") 396 | 397 | if start == nil || *start != parseTime("20150916T000000Z") || end == nil || *end != parseTime("20150916T000000Z") { 398 | t.Error("filter is returning wrong times") 399 | } 400 | 401 | // test with valid start time 402 | f = ResourceFilter{ 403 | attrs: map[string]string{ 404 | "start": "20150916T000000Z", 405 | "end": "invalid time", 406 | }, 407 | } 408 | 409 | start = f.TimeAttr("start") 410 | end = f.TimeAttr("end") 411 | 412 | if start == nil || *start != parseTime("20150916T000000Z") || end != nil { 413 | t.Error("filter is returning wrong times") 414 | } 415 | } 416 | 417 | func assertFilterMatch(filterXML string, res FakeResource, t *testing.T) { 418 | filter, err := ParseResourceFilters(filterXML) 419 | panicerr(err) 420 | if !filter.Match(&res) { 421 | t.Error("Filter should have been matched. Filter XML:", filterXML) 422 | } 423 | } 424 | 425 | func assertFilterDoesNotMatch(filterXML string, res FakeResource, t *testing.T) { 426 | filter, err := ParseResourceFilters(filterXML) 427 | panicerr(err) 428 | if filter.Match(&res) { 429 | t.Error("Filter should not have been matched. Filter XML:", filterXML) 430 | } 431 | } 432 | 433 | // Fake resource, that implements the ResourceInterface, to be used throughout the tests. 434 | type FakeResource struct { 435 | comp string 436 | start string 437 | end string 438 | recurrences []ResourceRecurrence 439 | properties map[string]string 440 | propertyParams map[string]string 441 | } 442 | 443 | func (r *FakeResource) ComponentName() string { 444 | if r.comp == "" { 445 | return "VEVENT" 446 | } 447 | 448 | return r.comp 449 | } 450 | 451 | func (r *FakeResource) StartTimeUTC() time.Time { 452 | return parseTime(r.start) 453 | } 454 | 455 | func (r *FakeResource) EndTimeUTC() time.Time { 456 | return parseTime(r.end) 457 | } 458 | 459 | func (r *FakeResource) Recurrences() []ResourceRecurrence { 460 | return r.recurrences 461 | } 462 | 463 | func (r *FakeResource) addRecurrence(startStr string, endStr string) { 464 | if r.recurrences == nil { 465 | r.recurrences = []ResourceRecurrence{} 466 | } 467 | 468 | r.recurrences = append(r.recurrences, ResourceRecurrence{ 469 | StartTime: parseTime(startStr), 470 | EndTime: parseTime(endStr), 471 | }) 472 | } 473 | 474 | func (r *FakeResource) HasProperty(propPath ...string) bool { 475 | if r.properties == nil { 476 | return false 477 | } 478 | 479 | propKey := r.getPropParamKey(propPath...) 480 | _, found := r.properties[propKey] 481 | return found 482 | } 483 | 484 | func (r *FakeResource) GetPropertyValue(propPath ...string) string { 485 | if r.properties == nil { 486 | return "" 487 | } 488 | 489 | propKey := r.getPropParamKey(propPath...) 490 | return r.properties[propKey] 491 | } 492 | 493 | func (r *FakeResource) HasPropertyParam(paramPath ...string) bool { 494 | if r.propertyParams == nil { 495 | return false 496 | } 497 | 498 | paramKey := r.getPropParamKey(paramPath...) 499 | _, found := r.propertyParams[paramKey] 500 | return found 501 | } 502 | 503 | func (r *FakeResource) GetPropertyParamValue(paramPath ...string) string { 504 | if r.propertyParams == nil { 505 | return "" 506 | } 507 | 508 | paramKey := r.getPropParamKey(paramPath...) 509 | return r.propertyParams[paramKey] 510 | } 511 | 512 | func (r *FakeResource) addProperty(propPath string, propValue string) { 513 | if r.properties == nil { 514 | r.properties = make(map[string]string) 515 | } 516 | 517 | r.properties[propPath] = propValue 518 | } 519 | 520 | func (r *FakeResource) addPropertyParam(paramPath string, paramValue string) { 521 | if r.propertyParams == nil { 522 | r.propertyParams = make(map[string]string) 523 | } 524 | 525 | r.propertyParams[paramPath] = paramValue 526 | } 527 | 528 | func (r *FakeResource) getPropParamKey(ppath ...string) string { 529 | return strings.Join(ppath, ":") 530 | } 531 | 532 | func parseTime(timeStr string) time.Time { 533 | timeParseFormat := "20060102T150405Z" 534 | t, _ := time.Parse(timeParseFormat, timeStr) 535 | return t 536 | } 537 | 538 | func panicerr(err error) { 539 | if err != nil { 540 | panic(err) 541 | } 542 | } 543 | -------------------------------------------------------------------------------- /data/resource.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/laurent22/ical-go" 13 | 14 | "github.com/samedi/caldav-go/files" 15 | "github.com/samedi/caldav-go/lib" 16 | ) 17 | 18 | // ResourceInterface defines the main interface of a CalDAV resource object. This 19 | // interface exists only to define the common resource operation and should not be custom-implemented. 20 | // The default and canonical implementation is provided by `data.Resource`, convering all the commonalities. 21 | // Any specifics in implementations should be handled by the `data.ResourceAdapter`. 22 | type ResourceInterface interface { 23 | ComponentName() string 24 | StartTimeUTC() time.Time 25 | EndTimeUTC() time.Time 26 | Recurrences() []ResourceRecurrence 27 | HasProperty(propPath ...string) bool 28 | GetPropertyValue(propPath ...string) string 29 | HasPropertyParam(paramName ...string) bool 30 | GetPropertyParamValue(paramName ...string) string 31 | } 32 | 33 | // ResourceAdapter serves as the object to abstract all the specicities in different resources implementations. 34 | // For example, the way to tell whether a resource is a collection or how to read its content differentiates 35 | // on resources stored in the file system, coming from a relational DB or from the cloud as JSON. These differentiations 36 | // should be covered by providing a specific implementation of the `ResourceAdapter` interface. So, depending on the current 37 | // resource storage strategy, a matching resource adapter implementation should be provided whenever a new resource is initialized. 38 | type ResourceAdapter interface { 39 | IsCollection() bool 40 | CalculateEtag() string 41 | GetContent() string 42 | GetContentSize() int64 43 | GetModTime() time.Time 44 | } 45 | 46 | // ResourceRecurrence represents a recurrence for a resource. 47 | // NOTE: recurrences are not supported yet. 48 | type ResourceRecurrence struct { 49 | StartTime time.Time 50 | EndTime time.Time 51 | } 52 | 53 | // Resource represents the CalDAV resource. Basically, it has a name it's accessible based on path. 54 | // A resource can be a collection, meaning it doesn't have any data content, but it has child resources. 55 | // A non-collection is the actual resource which has the data in iCal format and which will feed the calendar. 56 | // When visualizing the whole resources set in a tree representation, the collection resource would be the inner nodes and 57 | // the non-collection would be the leaves. 58 | type Resource struct { 59 | Name string 60 | Path string 61 | 62 | pathSplit []string 63 | adapter ResourceAdapter 64 | 65 | emptyTime time.Time 66 | } 67 | 68 | // NewResource initializes a new `Resource` instance based on its path and the `ResourceAdapter` implementation to be used. 69 | func NewResource(rawPath string, adp ResourceAdapter) Resource { 70 | pClean := lib.ToSlashPath(rawPath) 71 | pSplit := strings.Split(strings.Trim(pClean, "/"), "/") 72 | 73 | return Resource{ 74 | Name: pSplit[len(pSplit)-1], 75 | Path: pClean, 76 | pathSplit: pSplit, 77 | adapter: adp, 78 | } 79 | } 80 | 81 | // IsCollection tells whether a resource is a collection or not. 82 | func (r *Resource) IsCollection() bool { 83 | return r.adapter.IsCollection() 84 | } 85 | 86 | // IsPrincipal tells whether a resource is the principal resource or not. 87 | // A principal resource means it's a root resource. 88 | func (r *Resource) IsPrincipal() bool { 89 | return len(r.pathSplit) <= 1 90 | } 91 | 92 | // ComponentName returns the type of the resource. VCALENDAR for collection resources, VEVENT otherwise. 93 | func (r *Resource) ComponentName() string { 94 | if r.IsCollection() { 95 | return lib.VCALENDAR 96 | } 97 | 98 | return lib.VEVENT 99 | } 100 | 101 | // StartTimeUTC returns the start time in UTC of a VEVENT resource. 102 | func (r *Resource) StartTimeUTC() time.Time { 103 | vevent := r.icalVEVENT() 104 | dtstart := vevent.PropDate(ical.DTSTART, r.emptyTime) 105 | 106 | if dtstart == r.emptyTime { 107 | log.Printf("WARNING: The property DTSTART was not found in the resource's ical data.\nResource path: %s", r.Path) 108 | return r.emptyTime 109 | } 110 | 111 | return dtstart.UTC() 112 | } 113 | 114 | // EndTimeUTC returns the end time in UTC of a VEVENT resource. 115 | func (r *Resource) EndTimeUTC() time.Time { 116 | vevent := r.icalVEVENT() 117 | dtend := vevent.PropDate(ical.DTEND, r.emptyTime) 118 | 119 | // when the DTEND property is not present, we just add the DURATION (if any) to the DTSTART 120 | if dtend == r.emptyTime { 121 | duration := vevent.PropDuration(ical.DURATION) 122 | dtend = r.StartTimeUTC().Add(duration) 123 | } 124 | 125 | return dtend.UTC() 126 | } 127 | 128 | // Recurrences returns an array of resource recurrences. 129 | // NOTE: Recurrences are not supported yet. An empty array will always be returned. 130 | func (r *Resource) Recurrences() []ResourceRecurrence { 131 | // TODO: Implement. This server does not support iCal recurrences yet. We just return an empty array. 132 | return []ResourceRecurrence{} 133 | } 134 | 135 | // HasProperty tells whether the resource has the provided property in its iCal content. 136 | // The path to the property should be provided in case of nested properties. 137 | // Example, suppose the resource has this content: 138 | // 139 | // BEGIN:VCALENDAR 140 | // BEGIN:VEVENT 141 | // DTSTART:20160914T170000 142 | // END:VEVENT 143 | // END:VCALENDAR 144 | // 145 | // HasProperty("VEVENT", "DTSTART") => returns true 146 | // HasProperty("VEVENT", "DTEND") => returns false 147 | func (r *Resource) HasProperty(propPath ...string) bool { 148 | return r.GetPropertyValue(propPath...) != "" 149 | } 150 | 151 | // GetPropertyValue gets a property value from the resource's iCal content. 152 | // The path to the property should be provided in case of nested properties. 153 | // Example, suppose the resource has this content: 154 | // 155 | // BEGIN:VCALENDAR 156 | // BEGIN:VEVENT 157 | // DTSTART:20160914T170000 158 | // END:VEVENT 159 | // END:VCALENDAR 160 | // 161 | // GetPropertyValue("VEVENT", "DTSTART") => returns "20160914T170000" 162 | // GetPropertyValue("VEVENT", "DTEND") => returns "" 163 | func (r *Resource) GetPropertyValue(propPath ...string) string { 164 | if propPath[0] == ical.VCALENDAR { 165 | propPath = propPath[1:] 166 | } 167 | 168 | prop, _ := r.icalendar().DigProperty(propPath...) 169 | return prop 170 | } 171 | 172 | // HasPropertyParam tells whether the resource has the provided property param in its iCal content. 173 | // The path to the param should be provided in case of nested params. 174 | // Example, suppose the resource has this content: 175 | // 176 | // BEGIN:VCALENDAR 177 | // BEGIN:VEVENT 178 | // ATTENDEE;PARTSTAT=NEEDS-ACTION:FOO 179 | // END:VEVENT 180 | // END:VCALENDAR 181 | // 182 | // HasPropertyParam("VEVENT", "ATTENDEE", "PARTSTAT") => returns true 183 | // HasPropertyParam("VEVENT", "ATTENDEE", "OTHER") => returns false 184 | func (r *Resource) HasPropertyParam(paramPath ...string) bool { 185 | return r.GetPropertyParamValue(paramPath...) != "" 186 | } 187 | 188 | // GetPropertyParamValue gets a property param value from the resource's iCal content. 189 | // The path to the param should be provided in case of nested params. 190 | // Example, suppose the resource has this content: 191 | // 192 | // BEGIN:VCALENDAR 193 | // BEGIN:VEVENT 194 | // ATTENDEE;PARTSTAT=NEEDS-ACTION:FOO 195 | // END:VEVENT 196 | // END:VCALENDAR 197 | // 198 | // GetPropertyParamValue("VEVENT", "ATTENDEE", "PARTSTAT") => returns "NEEDS-ACTION" 199 | // GetPropertyParamValue("VEVENT", "ATTENDEE", "OTHER") => returns "" 200 | func (r *Resource) GetPropertyParamValue(paramPath ...string) string { 201 | if paramPath[0] == ical.VCALENDAR { 202 | paramPath = paramPath[1:] 203 | } 204 | 205 | param, _ := r.icalendar().DigParameter(paramPath...) 206 | return param 207 | } 208 | 209 | // GetEtag returns the ETag of the resource and a flag saying if the ETag is present. 210 | // For collection resource, it returns an empty string and false. 211 | func (r *Resource) GetEtag() (string, bool) { 212 | if r.IsCollection() { 213 | return "", false 214 | } 215 | 216 | return r.adapter.CalculateEtag(), true 217 | } 218 | 219 | // GetContentType returns the type of the content of the resource. 220 | // Collection resources are "text/calendar". Non-collection resources are "text/calendar; component=vcalendar". 221 | func (r *Resource) GetContentType() (string, bool) { 222 | if r.IsCollection() { 223 | return "text/calendar", true 224 | } 225 | 226 | return "text/calendar; component=vcalendar", true 227 | } 228 | 229 | // GetDisplayName returns the name/identifier of the resource. 230 | func (r *Resource) GetDisplayName() (string, bool) { 231 | return r.Name, true 232 | } 233 | 234 | // GetContentData reads and returns the raw content of the resource as string and flag saying if the content was found. 235 | // If the resource does not have content (like collection resource), it returns an empty string and false. 236 | func (r *Resource) GetContentData() (string, bool) { 237 | data := r.adapter.GetContent() 238 | found := data != "" 239 | 240 | return data, found 241 | } 242 | 243 | // GetContentLength returns the length of the resource's content and flag saying if the length is present. 244 | // If the resource does not have content (like collection resource), it returns an empty string and false. 245 | func (r *Resource) GetContentLength() (string, bool) { 246 | // If its collection, it does not have any content, so mark it as not found 247 | if r.IsCollection() { 248 | return "", false 249 | } 250 | 251 | contentSize := r.adapter.GetContentSize() 252 | return strconv.FormatInt(contentSize, 10), true 253 | } 254 | 255 | // GetLastModified returns the last time the resource was modified. The returned time 256 | // is returned formatted in the provided `format`. 257 | func (r *Resource) GetLastModified(format string) (string, bool) { 258 | return r.adapter.GetModTime().Format(format), true 259 | } 260 | 261 | // GetOwner returns the owner of the resource. This is usually the principal resource associated (the root resource). 262 | // If the resource does not have a owner (for example it's a principal resource alread), it returns an empty string. 263 | func (r *Resource) GetOwner() (string, bool) { 264 | var owner string 265 | if len(r.pathSplit) > 1 { 266 | owner = r.pathSplit[0] 267 | } else { 268 | owner = "" 269 | } 270 | 271 | return owner, true 272 | } 273 | 274 | // GetOwnerPath returns the path to this resource's owner, or an empty string when the resource does not have any owner. 275 | func (r *Resource) GetOwnerPath() (string, bool) { 276 | owner, _ := r.GetOwner() 277 | 278 | if owner != "" { 279 | return fmt.Sprintf("/%s/", owner), true 280 | } 281 | 282 | return "", false 283 | } 284 | 285 | // TODO: memoize 286 | func (r *Resource) icalVEVENT() *ical.Node { 287 | vevent := r.icalendar().ChildByName(ical.VEVENT) 288 | 289 | // if nil, log it and return an empty vevent 290 | if vevent == nil { 291 | log.Printf("WARNING: The resource's ical data is missing the VEVENT property.\nResource path: %s", r.Path) 292 | 293 | return &ical.Node{ 294 | Name: ical.VEVENT, 295 | } 296 | } 297 | 298 | return vevent 299 | } 300 | 301 | // TODO: memoize 302 | func (r *Resource) icalendar() *ical.Node { 303 | data, found := r.GetContentData() 304 | 305 | if !found { 306 | log.Printf("WARNING: The resource's ical data does not have any data.\nResource path: %s", r.Path) 307 | return &ical.Node{ 308 | Name: ical.VCALENDAR, 309 | } 310 | } 311 | 312 | icalNode, err := ical.ParseCalendar(data) 313 | if err != nil { 314 | log.Printf("ERROR: Could not parse the resource's ical data.\nError: %s.\nResource path: %s", err, r.Path) 315 | return &ical.Node{ 316 | Name: ical.VCALENDAR, 317 | } 318 | } 319 | 320 | return icalNode 321 | } 322 | 323 | // FileResourceAdapter implements the `ResourceAdapter` for resources stored as files in the file system. 324 | type FileResourceAdapter struct { 325 | finfo os.FileInfo 326 | resourcePath string 327 | } 328 | 329 | // IsCollection tells whether the file resource is a directory or not. 330 | func (adp *FileResourceAdapter) IsCollection() bool { 331 | return adp.finfo.IsDir() 332 | } 333 | 334 | // GetContent reads the file content and returns it as string. For collection resources (directories), it 335 | // returns an empty string. 336 | func (adp *FileResourceAdapter) GetContent() string { 337 | if adp.IsCollection() { 338 | return "" 339 | } 340 | 341 | data, err := ioutil.ReadFile(files.AbsPath(adp.resourcePath)) 342 | if err != nil { 343 | log.Printf("ERROR: Could not read file content for the resource.\nError: %s.\nResource path: %s.", err, adp.resourcePath) 344 | return "" 345 | } 346 | 347 | return string(data) 348 | } 349 | 350 | // GetContentSize returns the content length. 351 | func (adp *FileResourceAdapter) GetContentSize() int64 { 352 | return adp.finfo.Size() 353 | } 354 | 355 | // CalculateEtag calculates an ETag based on the file current modification status and returns it. 356 | func (adp *FileResourceAdapter) CalculateEtag() string { 357 | // returns ETag as the concatenated hex values of a file's 358 | // modification time and size. This is not a reliable synchronization 359 | // mechanism for directories, so for collections we return empty. 360 | if adp.IsCollection() { 361 | return "" 362 | } 363 | 364 | fi := adp.finfo 365 | return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()) 366 | } 367 | 368 | // GetModTime returns the time when the file was last modified. 369 | func (adp *FileResourceAdapter) GetModTime() time.Time { 370 | return adp.finfo.ModTime() 371 | } 372 | -------------------------------------------------------------------------------- /data/resource_test.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestNewResource(t *testing.T) { 10 | res := NewResource("/foo///bar/123.ics//", FakeResourceAdapter{}) 11 | 12 | if res.Name != "123.ics" { 13 | t.Error("Expected name to be 123.ics, got", res.Name) 14 | } 15 | 16 | // it cleans (sanitize) the path 17 | if res.Path != "/foo/bar/123.ics" { 18 | t.Error("Expected name to be /foo/bar/123.ics, got", res.Path) 19 | } 20 | } 21 | 22 | func TestIsCollection(t *testing.T) { 23 | adp := new(FakeResourceAdapter) 24 | res := NewResource("/foo/bar/", adp) 25 | 26 | adp.collection = false 27 | if res.IsCollection() { 28 | t.Error("Resource should not be a collection") 29 | } 30 | 31 | adp.collection = true 32 | if !res.IsCollection() { 33 | t.Error("Resource should be a collection") 34 | } 35 | } 36 | 37 | func TestIsPrincipal(t *testing.T) { 38 | res := NewResource("/foo", FakeResourceAdapter{}) 39 | if !res.IsPrincipal() { 40 | t.Error("Resource should be principal") 41 | } 42 | 43 | res = NewResource("/foo/bar", FakeResourceAdapter{}) 44 | if res.IsPrincipal() { 45 | t.Error("Resource should not be principal") 46 | } 47 | } 48 | 49 | func TestComponentName(t *testing.T) { 50 | adp := new(FakeResourceAdapter) 51 | res := NewResource("/foo", adp) 52 | 53 | adp.collection = false 54 | if res.ComponentName() != "VEVENT" { 55 | t.Error("Resource should be a VEVENT") 56 | } 57 | 58 | adp.collection = true 59 | if res.ComponentName() != "VCALENDAR" { 60 | t.Error("Resource should be a VCALENDAR") 61 | } 62 | } 63 | 64 | func TestEtag(t *testing.T) { 65 | adp := new(FakeResourceAdapter) 66 | res := NewResource("/foo", adp) 67 | 68 | adp.collection = false 69 | adp.etag = "1111" 70 | etag, found := res.GetEtag() 71 | if etag != "1111" || !found { 72 | t.Error("Etag should be 1111") 73 | } 74 | 75 | adp.etag = "2222" 76 | etag, found = res.GetEtag() 77 | if etag != "2222" || !found { 78 | t.Error("Etag should be 2222") 79 | } 80 | 81 | adp.collection = true 82 | etag, found = res.GetEtag() 83 | if etag != "" || found { 84 | t.Error("Collections should not have etags associated") 85 | } 86 | } 87 | 88 | func TestContentType(t *testing.T) { 89 | adp := new(FakeResourceAdapter) 90 | res := NewResource("/foo", adp) 91 | 92 | adp.collection = false 93 | ctype, found := res.GetContentType() 94 | if ctype != "text/calendar; component=vcalendar" || !found { 95 | t.Error("Content Type should be `text/calendar; component=vcalendar`") 96 | } 97 | 98 | adp.collection = true 99 | ctype, found = res.GetContentType() 100 | if ctype != "text/calendar" || !found { 101 | t.Error("Content Type should be `text/calendar`") 102 | } 103 | } 104 | 105 | func TestDisplayName(t *testing.T) { 106 | res := NewResource("foo/bar", FakeResourceAdapter{}) 107 | 108 | // it just returns the resource Name 109 | name, found := res.GetDisplayName() 110 | if name != res.Name || !found { 111 | t.Error("Display name should be", res.Name) 112 | } 113 | } 114 | 115 | func TestContentData(t *testing.T) { 116 | adp := new(FakeResourceAdapter) 117 | res := NewResource("/foo", adp) 118 | 119 | adp.contentData = "EVENT;" 120 | adp.collection = false 121 | 122 | data, found := res.GetContentData() 123 | if data != "EVENT;" || !found { 124 | t.Error("Content data should be EVENT;") 125 | } 126 | } 127 | 128 | func TestContentLength(t *testing.T) { 129 | adp := new(FakeResourceAdapter) 130 | res := NewResource("foo", adp) 131 | 132 | adp.contentSize = 42 133 | 134 | adp.collection = false 135 | clength, found := res.GetContentLength() 136 | if clength != "42" || !found { 137 | t.Error("Content length should be 42") 138 | } 139 | 140 | adp.collection = true 141 | clength, found = res.GetContentLength() 142 | if clength != "" || found { 143 | t.Error("Content length should be marked as not found for collections") 144 | } 145 | } 146 | 147 | func TestLastModified(t *testing.T) { 148 | adp := new(FakeResourceAdapter) 149 | res := NewResource("foo", adp) 150 | 151 | adp.modtime = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC) 152 | timeFormat := "2006-01-02 15:04:05" 153 | lastmod, found := res.GetLastModified(timeFormat) 154 | 155 | if lastmod != "2009-11-10 23:00:00" || !found { 156 | t.Error("Last modified should be equal `2009-11-10 23:00:00`") 157 | } 158 | } 159 | 160 | func TestOwnerPath(t *testing.T) { 161 | res := NewResource("/foo", FakeResourceAdapter{}) 162 | owner, found := res.GetOwnerPath() 163 | if owner != "" || found { 164 | t.Error("Path owner should have been empty") 165 | } 166 | 167 | res = NewResource("/foo/bar", FakeResourceAdapter{}) 168 | owner, found = res.GetOwnerPath() 169 | if owner != "/foo/" || !found { 170 | t.Error("Path owner should have been `/foo/`") 171 | } 172 | } 173 | 174 | func TestStartEndTimesUTC(t *testing.T) { 175 | newResource := func(timeInfo string) Resource { 176 | adp := new(FakeResourceAdapter) 177 | adp.contentData = fmt.Sprintf(` 178 | BEGIN:VCALENDAR 179 | BEGIN:VTIMEZONE 180 | TZID:Europe/Berlin 181 | BEGIN:DAYLIGHT 182 | TZOFFSETFROM:+0100 183 | TZOFFSETTO:+0200 184 | TZNAME:CEST 185 | DTSTART:19700329T020000 186 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 187 | END:DAYLIGHT 188 | BEGIN:STANDARD 189 | TZOFFSETFROM:+0200 190 | TZOFFSETTO:+0100 191 | TZNAME:CET 192 | DTSTART:19701025T030000 193 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 194 | END:STANDARD 195 | END:VTIMEZONE 196 | BEGIN:VEVENT 197 | %s 198 | END:VEVENT 199 | END:VCALENDAR 200 | `, timeInfo) 201 | 202 | return NewResource("/foo", adp) 203 | } 204 | 205 | assertTime := func(target, expected time.Time) { 206 | if !(target == expected) { 207 | t.Error("Wrong resource time. Expected:", expected, "Got:", target) 208 | } 209 | } 210 | 211 | res := newResource(` 212 | DTSTART;TZID=Europe/Berlin:20160914T170000 213 | DTEND;TZID=Europe/Berlin:20160915T180000 214 | `) 215 | 216 | // test start time in UTC 217 | assertTime(res.StartTimeUTC(), time.Date(2016, 9, 14, 15, 0, 0, 0, time.UTC)) 218 | // test end time in UTC 219 | assertTime(res.EndTimeUTC(), time.Date(2016, 9, 15, 16, 0, 0, 0, time.UTC)) 220 | 221 | // test `end` time in UTC when DTEND is not present but DURATION is 222 | // in this case, the `end` time has to be DTSTART + DURATION 223 | 224 | res = newResource(` 225 | DTSTART;TZID=Europe/Berlin:20160914T170000 226 | DURATION:PT3H10M1S 227 | `) 228 | 229 | assertTime(res.EndTimeUTC(), time.Date(2016, 9, 14, 18, 10, 1, 0, time.UTC)) 230 | 231 | res = newResource(` 232 | DTSTART;TZID=Europe/Berlin:20160914T170000 233 | DURATION:PT10M 234 | `) 235 | 236 | assertTime(res.EndTimeUTC(), time.Date(2016, 9, 14, 15, 10, 0, 0, time.UTC)) 237 | 238 | res = newResource(` 239 | DTSTART;TZID=Europe/Berlin:20160914T170000 240 | DURATION:PT1S 241 | `) 242 | 243 | assertTime(res.EndTimeUTC(), time.Date(2016, 9, 14, 15, 0, 1, 0, time.UTC)) 244 | 245 | // test end time in UTC when DTEND and DURATION are not present 246 | // in this case, the `end` time has to be equals to DTSTART time 247 | 248 | res = newResource(` 249 | DTSTART;TZID=Europe/Berlin:20160914T170000 250 | `) 251 | 252 | assertTime(res.EndTimeUTC(), time.Date(2016, 9, 14, 15, 0, 0, 0, time.UTC)) 253 | } 254 | 255 | func TestProperties(t *testing.T) { 256 | adp := new(FakeResourceAdapter) 257 | res := NewResource("/foo", adp) 258 | 259 | adp.contentData = ` 260 | BEGIN:VCALENDAR 261 | BEGIN:VEVENT 262 | DTSTART:20160914T170000 263 | END:VEVENT 264 | END:VCALENDAR 265 | ` 266 | 267 | // asserts that the resource does not have the property VEVENT->DTEND 268 | if res.HasProperty("VEVENT", "DTEND") || res.GetPropertyValue("VEVENT", "DTEND") != "" { 269 | t.Error("Resource should not have the property") 270 | } 271 | 272 | adp.contentData = ` 273 | BEGIN:VCALENDAR 274 | BEGIN:VEVENT 275 | DTSTART:20160914T170000 276 | DTEND:20160915T170000 277 | END:VEVENT 278 | END:VCALENDAR 279 | ` 280 | 281 | // asserts that the resource has the property VEVENT->DTEND and it returns the correct value 282 | if !res.HasProperty("VEVENT", "DTEND") || res.GetPropertyValue("VEVENT", "DTEND") != "20160915T170000" { 283 | t.Error("Resource should have the property") 284 | } 285 | 286 | // asserts that the resource has the property VCALENDAR->VEVENT->DTEND and it returns the correct value 287 | // (VCALENDAR is ignored when passing the prop path) 288 | if !res.HasProperty("VCALENDAR", "VEVENT", "DTEND") || res.GetPropertyValue("VCALENDAR", "VEVENT", "DTEND") == "" { 289 | t.Error("Resource should have the property") 290 | } 291 | } 292 | 293 | func TestPropertyParams(t *testing.T) { 294 | adp := new(FakeResourceAdapter) 295 | res := NewResource("/foo", adp) 296 | 297 | adp.contentData = ` 298 | BEGIN:VCALENDAR 299 | BEGIN:VEVENT 300 | ATTENDEE:FOO 301 | END:VEVENT 302 | END:VCALENDAR 303 | ` 304 | 305 | // asserts that the resource does not have the property param VEVENT->ATTENDEE->PARTSTAT 306 | if res.HasPropertyParam("VEVENT", "ATTENDEE", "PARTSTAT") || res.GetPropertyParamValue("VEVENT", "ATTENDEE", "PARTSTAT") != "" { 307 | t.Error("Resouce should not have the property param") 308 | } 309 | 310 | adp.contentData = ` 311 | BEGIN:VCALENDAR 312 | BEGIN:VEVENT 313 | ATTENDEE;PARTSTAT=NEEDS-ACTION:FOO 314 | END:VEVENT 315 | END:VCALENDAR 316 | ` 317 | 318 | // asserts that the resource has the property param VEVENT->ATTENDEE->PARTSTAT and it returns the correct value 319 | if !res.HasPropertyParam("VEVENT", "ATTENDEE", "PARTSTAT") || res.GetPropertyParamValue("VEVENT", "ATTENDEE", "PARTSTAT") != "NEEDS-ACTION" { 320 | t.Error("Resource should have the property param") 321 | } 322 | 323 | // asserts that the resource has the property VEVENT->ATTENDEE->PARTSTAT and it returns the correct value 324 | // (VCALENDAR is ignored when passing the prop path) 325 | if !res.HasPropertyParam("VCALENDAR", "VEVENT", "ATTENDEE", "PARTSTAT") || res.GetPropertyParamValue("VCALENDAR", "VEVENT", "ATTENDEE", "PARTSTAT") != "NEEDS-ACTION" { 326 | t.Error("Resource should have the property param") 327 | } 328 | } 329 | 330 | type FakeResourceAdapter struct { 331 | collection bool 332 | etag string 333 | contentData string 334 | contentSize int64 335 | modtime time.Time 336 | } 337 | 338 | func (adp FakeResourceAdapter) IsCollection() bool { 339 | return adp.collection 340 | } 341 | 342 | func (adp FakeResourceAdapter) GetContent() string { 343 | return adp.contentData 344 | } 345 | 346 | func (adp FakeResourceAdapter) GetContentSize() int64 { 347 | return adp.contentSize 348 | } 349 | 350 | func (adp FakeResourceAdapter) CalculateEtag() string { 351 | return adp.etag 352 | } 353 | 354 | func (adp FakeResourceAdapter) GetModTime() time.Time { 355 | return adp.modtime 356 | } 357 | -------------------------------------------------------------------------------- /data/storage.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/samedi/caldav-go/errs" 5 | "github.com/samedi/caldav-go/files" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | ) 10 | 11 | // Storage is the inteface responsible for the CRUD operations on the CalDAV resources. It represents 12 | // where the resources should be fetched from and the various operations which can be performed on it. 13 | // This is the interface one should implement in case it needs a custom storage strategy, like fetching 14 | // data from the cloud, local DB, etc. After that, the custom storage implementation can be setup to be used 15 | // in the server by passing the object instance to `caldav.SetupStorage`. 16 | type Storage interface { 17 | // GetResources gets a list of resources based on a given `rpath`. The 18 | // `rpath` is the path to the original resource that's being requested. The resultant list 19 | // will/must contain that original resource in it, apart from any additional resources. It also receives 20 | // `withChildren` flag to say if the result must also include all the original resource`s 21 | // children (if original is a collection resource). If `true`, the result will have the requested resource + children. 22 | // If `false`, it will have only the requested original resource (from the `rpath` path). 23 | // It returns errors if anything went wrong or if it could not find any resource on `rpath` path. 24 | GetResources(rpath string, withChildren bool) ([]Resource, error) 25 | // GetResourcesByList fetches a list of resources by path from the storage. 26 | // This method fetches all the `rpaths` and return an array of the reosurces found. 27 | // No error 404 will be returned if one of the resources cannot be found. 28 | // Errors are returned if any errors other than "not found" happens. 29 | GetResourcesByList(rpaths []string) ([]Resource, error) 30 | // GetResourcesByFilters returns the filtered children of a target collection resource. 31 | // The target collection resource is the one pointed by the `rpath` parameter. All of its children 32 | // will be checked against a set of `filters` and the matching ones are returned. The results 33 | // contains only the filtered children and does NOT include the target resource. If the target resource 34 | // is not a collection, an empty array is returned as the result. 35 | GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error) 36 | // GetResource gets the requested resource based on a given `rpath` path. It returns the resource (if found) or 37 | // nil (if not found). Also returns a flag specifying if the resource was found or not. 38 | GetResource(rpath string) (*Resource, bool, error) 39 | // GetShallowResource has the same behaviour of `storage.GetResource`. The only difference is that, for collection resources, 40 | // it does not return its children in the collection `storage.Resource` struct (hence the name shallow). The motive is 41 | // for optimizations reasons, as this function is used on places where the collection's children are not important. 42 | GetShallowResource(rpath string) (*Resource, bool, error) 43 | // CreateResource creates a new resource on the `rpath` path with a given `content`. 44 | CreateResource(rpath, content string) (*Resource, error) 45 | // UpdateResource udpates a resource on the `rpath` path with a given `content`. 46 | UpdateResource(rpath, content string) (*Resource, error) 47 | // DeleteResource deletes a resource on the `rpath` path. 48 | DeleteResource(rpath string) error 49 | } 50 | 51 | // FileStorage is the storage that deals with resources as files in the file system. So, a collection resource 52 | // is treated as a folder/directory and its children resources are the files it contains. Non-collection resources are just plain files. 53 | // Each file represents then a CalAV resource and the data expects to contain the iCal data to feed the calendar events. 54 | type FileStorage struct { 55 | } 56 | 57 | // GetResources get the file resources based on the `rpath`. See `Storage.GetResources` doc. 58 | func (fs *FileStorage) GetResources(rpath string, withChildren bool) ([]Resource, error) { 59 | result := []Resource{} 60 | 61 | // tries to open the file by the given path 62 | f, e := fs.openResourceFile(rpath, os.O_RDONLY) 63 | if e != nil { 64 | return nil, e 65 | } 66 | 67 | // add it as a resource to the result list 68 | finfo, _ := f.Stat() 69 | resource := NewResource(rpath, &FileResourceAdapter{finfo, rpath}) 70 | result = append(result, resource) 71 | 72 | // if the file is a dir, add its children to the result list 73 | if withChildren && finfo.IsDir() { 74 | dirFiles, _ := f.Readdir(0) 75 | for _, finfo := range dirFiles { 76 | childPath := files.JoinPaths(rpath, finfo.Name()) 77 | resource = NewResource(childPath, &FileResourceAdapter{finfo, childPath}) 78 | result = append(result, resource) 79 | } 80 | } 81 | 82 | return result, nil 83 | } 84 | 85 | // GetResourcesByFilters get the file resources based on the `rpath` and a set of filters. See `Storage.GetResourcesByFilters` doc. 86 | func (fs *FileStorage) GetResourcesByFilters(rpath string, filters *ResourceFilter) ([]Resource, error) { 87 | result := []Resource{} 88 | 89 | childPaths := fs.getDirectoryChildPaths(rpath) 90 | for _, path := range childPaths { 91 | resource, _, err := fs.GetShallowResource(path) 92 | 93 | if err != nil { 94 | // if we can't find this resource, something weird went wrong, but not that serious, so we log it and continue 95 | log.Printf("WARNING: returned error when trying to get resource with path %s from collection with path %s. Error: %s", path, rpath, err) 96 | continue 97 | } 98 | 99 | // only add it if the resource matches the filters 100 | if filters == nil || filters.Match(resource) { 101 | result = append(result, *resource) 102 | } 103 | } 104 | 105 | return result, nil 106 | } 107 | 108 | // GetResourcesByList get a list of file resources based on a list of `rpaths`. See `Storage.GetResourcesByList` doc. 109 | func (fs *FileStorage) GetResourcesByList(rpaths []string) ([]Resource, error) { 110 | results := []Resource{} 111 | 112 | for _, rpath := range rpaths { 113 | resource, found, err := fs.GetShallowResource(rpath) 114 | 115 | if err != nil && err != errs.ResourceNotFoundError { 116 | return nil, err 117 | } 118 | 119 | if found { 120 | results = append(results, *resource) 121 | } 122 | } 123 | 124 | return results, nil 125 | } 126 | 127 | // GetResource fetches and returns a single resource for a `rpath`. See `Storage.GetResoure` doc. 128 | func (fs *FileStorage) GetResource(rpath string) (*Resource, bool, error) { 129 | // For simplicity we just return the shallow resource. 130 | return fs.GetShallowResource(rpath) 131 | } 132 | 133 | // GetShallowResource fetches and returns a single resource file/directory without any related children. See `Storage.GetShallowResource` doc. 134 | func (fs *FileStorage) GetShallowResource(rpath string) (*Resource, bool, error) { 135 | resources, err := fs.GetResources(rpath, false) 136 | 137 | if err != nil { 138 | return nil, false, err 139 | } 140 | 141 | if resources == nil || len(resources) == 0 { 142 | return nil, false, errs.ResourceNotFoundError 143 | } 144 | 145 | res := resources[0] 146 | return &res, true, nil 147 | } 148 | 149 | // CreateResource creates a file resource with the provided `content`. See `Storage.CreateResource` doc. 150 | func (fs *FileStorage) CreateResource(rpath, content string) (*Resource, error) { 151 | rAbsPath := files.AbsPath(rpath) 152 | 153 | if fs.isResourcePresent(rAbsPath) { 154 | return nil, errs.ResourceAlreadyExistsError 155 | } 156 | 157 | // create parent directories (if needed) 158 | if err := os.MkdirAll(files.DirPath(rAbsPath), os.ModePerm); err != nil { 159 | return nil, err 160 | } 161 | 162 | // create file/resource and write content 163 | f, err := os.Create(rAbsPath) 164 | if err != nil { 165 | return nil, err 166 | } 167 | f.WriteString(content) 168 | 169 | finfo, _ := f.Stat() 170 | res := NewResource(rpath, &FileResourceAdapter{finfo, rpath}) 171 | return &res, nil 172 | } 173 | 174 | // UpdateResource updates a file resource with the provided `content`. See `Storage.UpdateResource` doc. 175 | func (fs *FileStorage) UpdateResource(rpath, content string) (*Resource, error) { 176 | f, e := fs.openResourceFile(rpath, os.O_RDWR) 177 | if e != nil { 178 | return nil, e 179 | } 180 | 181 | // update content 182 | f.Truncate(0) 183 | f.WriteString(content) 184 | 185 | finfo, _ := f.Stat() 186 | res := NewResource(rpath, &FileResourceAdapter{finfo, rpath}) 187 | return &res, nil 188 | } 189 | 190 | // DeleteResource deletes a file resource (and possibly all its children in case of a collection). See `Storage.DeleteResource` doc. 191 | func (fs *FileStorage) DeleteResource(rpath string) error { 192 | err := os.Remove(files.AbsPath(rpath)) 193 | 194 | return err 195 | } 196 | 197 | func (fs *FileStorage) isResourcePresent(rpath string) bool { 198 | _, found, _ := fs.GetShallowResource(rpath) 199 | 200 | return found 201 | } 202 | 203 | func (fs *FileStorage) openResourceFile(filepath string, mode int) (*os.File, error) { 204 | f, e := os.OpenFile(files.AbsPath(filepath), mode, 0666) 205 | if e != nil { 206 | if os.IsNotExist(e) { 207 | return nil, errs.ResourceNotFoundError 208 | } 209 | return nil, e 210 | } 211 | 212 | return f, nil 213 | } 214 | 215 | func (fs *FileStorage) getDirectoryChildPaths(dirpath string) []string { 216 | content, err := ioutil.ReadDir(files.AbsPath(dirpath)) 217 | if err != nil { 218 | log.Printf("ERROR: Could not read resource as file directory.\nError: %s.\nResource path: %s.", err, dirpath) 219 | return nil 220 | } 221 | 222 | result := []string{} 223 | for _, file := range content { 224 | fpath := files.JoinPaths(dirpath, file.Name()) 225 | result = append(result, fpath) 226 | } 227 | 228 | return result 229 | } 230 | -------------------------------------------------------------------------------- /data/user.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | // CalUser represents the calendar user. It is used, for example, to 4 | // keep track globally what is the current user interacting with the calendar. 5 | // This user data can be used in various places, including in some of the CALDAV responses. 6 | type CalUser struct { 7 | Name string 8 | } 9 | -------------------------------------------------------------------------------- /errs/errors.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ResourceNotFoundError = errors.New("caldav: resource not found") 9 | ResourceAlreadyExistsError = errors.New("caldav: resource already exists") 10 | UnauthorizedError = errors.New("caldav: unauthorized. credentials needed.") 11 | ForbiddenError = errors.New("caldav: forbidden operation.") 12 | ) 13 | -------------------------------------------------------------------------------- /files/paths.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "github.com/samedi/caldav-go/lib" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | Separator = string(filepath.Separator) 11 | ) 12 | 13 | // AbsPath converts the path into absolute path based on the current working directory. 14 | func AbsPath(path string) string { 15 | path = strings.Trim(path, "/") 16 | absPath, _ := filepath.Abs(path) 17 | 18 | return absPath 19 | } 20 | 21 | // DirPath returns all but the last element of path, typically the path's directory. 22 | func DirPath(path string) string { 23 | return filepath.Dir(path) 24 | } 25 | 26 | // JoinPaths joins two or more paths into a single path. 27 | func JoinPaths(paths ...string) string { 28 | return filepath.Join(paths...) 29 | } 30 | 31 | // ToSlashPath slashify the path, using '/' as separator. 32 | func ToSlashPath(path string) string { 33 | return lib.ToSlashPath(path) 34 | } 35 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: f35dd88140d6d029e1e14352bfca5b72b6a7d5d99dbd991e6085a2fb062a46d9 2 | updated: 2019-06-10T11:38:46.099961846+02:00 3 | imports: 4 | - name: github.com/beevik/etree 5 | version: af219c0c7ea1b67ec263c0b1b1b96d284a9181ce 6 | - name: github.com/laurent22/ical-go 7 | version: e4fec34929693e2a4ba299d16380c55bac3fb42c 8 | - name: github.com/yosssi/gohtml 9 | version: 9b7db94d32d9901e7ad2488fddf91f7b9fcc36c8 10 | - name: golang.org/x/net 11 | version: c92cdcb05f66ce5b61460e23498d2dc5fcf69aaf 12 | subpackages: 13 | - html 14 | - html/atom 15 | testImports: [] 16 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/samedi/caldav-go 2 | import: 3 | - package: github.com/beevik/etree 4 | - package: github.com/laurent22/ical-go 5 | - package: github.com/yosssi/gohtml 6 | - package: golang.org/x/net 7 | subpackages: 8 | - html 9 | -------------------------------------------------------------------------------- /global/global.go: -------------------------------------------------------------------------------- 1 | // Package global defines the globally accessible variables in the caldav server 2 | // and the interface to setup them. 3 | package global 4 | 5 | import ( 6 | "github.com/samedi/caldav-go/data" 7 | "github.com/samedi/caldav-go/lib" 8 | ) 9 | 10 | // Storage represents the global storage used in the CRUD operations of resources. Default storage is the `data.FileStorage`. 11 | var Storage data.Storage = new(data.FileStorage) 12 | 13 | // User defines the current caldav user, which is the user currently interacting with the calendar. 14 | var User *data.CalUser 15 | 16 | // SupportedComponents contains all components which are supported by the current storage implementation 17 | var SupportedComponents = []string{lib.VCALENDAR, lib.VEVENT} 18 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package caldav 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/samedi/caldav-go/data" 7 | "github.com/samedi/caldav-go/handlers" 8 | ) 9 | 10 | // RequestHandler handles the given CALDAV request and writes the reponse righ away. This function is to be 11 | // used by passing it directly as the handle func to the `http` lib. Example: http.HandleFunc("/", caldav.RequestHandler). 12 | func RequestHandler(writer http.ResponseWriter, request *http.Request) { 13 | response := HandleRequest(request) 14 | response.Write(writer) 15 | } 16 | 17 | // HandleRequest handles the given CALDAV request and returns the response. Useful when the caller 18 | // wants to do something else with the response before writing it to the response stream. 19 | func HandleRequest(request *http.Request) *handlers.Response { 20 | handler := handlers.NewHandler(request) 21 | return handler.Handle() 22 | } 23 | 24 | // HandleRequestWithStorage handles the request the same way as `HandleRequest` does, but before, 25 | // it sets the given storage that will be used throughout the request handling flow. 26 | func HandleRequestWithStorage(request *http.Request, stg data.Storage) *handlers.Response { 27 | SetupStorage(stg) 28 | return HandleRequest(request) 29 | } 30 | -------------------------------------------------------------------------------- /handlers/builder.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/samedi/caldav-go/data" 7 | "github.com/samedi/caldav-go/global" 8 | ) 9 | 10 | // HandlerInterface represents a CalDAV request handler. It has only one function `Handle`, 11 | // which is used to handle the CalDAV request and returns the response. 12 | type HandlerInterface interface { 13 | Handle() *Response 14 | } 15 | 16 | // Common data shared across the specific handlers. Defined here to 17 | // easily make available, in a single place, all the basic data possibly needed by the handlers. 18 | type handlerData struct { 19 | request *http.Request 20 | requestBody string 21 | requestPath string 22 | headers headers 23 | response *Response 24 | storage data.Storage 25 | } 26 | 27 | // NewHandler returns a new CalDAV request handler object based on the provided request. 28 | // With the returned request handler, you can call `Handle()` to handle the request. 29 | func NewHandler(request *http.Request) HandlerInterface { 30 | hData := handlerData{ 31 | request: request, 32 | requestBody: readRequestBody(request), 33 | requestPath: request.URL.Path, 34 | headers: headers{request.Header}, 35 | response: NewResponse(), 36 | storage: global.Storage, 37 | } 38 | 39 | switch request.Method { 40 | case "GET": 41 | return getHandler{handlerData: hData, onlyHeaders: false} 42 | case "HEAD": 43 | return getHandler{handlerData: hData, onlyHeaders: true} 44 | case "PUT": 45 | return putHandler{hData} 46 | case "DELETE": 47 | return deleteHandler{hData} 48 | case "PROPFIND": 49 | return propfindHandler{hData} 50 | case "OPTIONS": 51 | return optionsHandler{hData} 52 | case "REPORT": 53 | return reportHandler{hData} 54 | default: 55 | return notImplementedHandler{hData} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /handlers/delete.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type deleteHandler struct { 8 | handlerData 9 | } 10 | 11 | func (dh deleteHandler) Handle() *Response { 12 | precond := requestPreconditions{dh.request} 13 | 14 | // get the event from the storage 15 | resource, _, err := dh.storage.GetShallowResource(dh.requestPath) 16 | if err != nil { 17 | return dh.response.SetError(err) 18 | } 19 | 20 | // TODO: Handle delete on collections 21 | if resource.IsCollection() { 22 | return dh.response.Set(http.StatusMethodNotAllowed, "") 23 | } 24 | 25 | // check ETag pre-condition 26 | resourceEtag, _ := resource.GetEtag() 27 | if !precond.IfMatch(resourceEtag) { 28 | return dh.response.Set(http.StatusPreconditionFailed, "") 29 | } 30 | 31 | // delete event after pre-condition passed 32 | err = dh.storage.DeleteResource(resource.Path) 33 | if err != nil { 34 | return dh.response.SetError(err) 35 | } 36 | 37 | return dh.response.Set(http.StatusNoContent, "") 38 | } 39 | -------------------------------------------------------------------------------- /handlers/get.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type getHandler struct { 8 | handlerData 9 | onlyHeaders bool 10 | } 11 | 12 | func (gh getHandler) Handle() *Response { 13 | resource, _, err := gh.storage.GetResource(gh.requestPath) 14 | if err != nil { 15 | return gh.response.SetError(err) 16 | } 17 | 18 | var response string 19 | if gh.onlyHeaders { 20 | response = "" 21 | } else { 22 | response, _ = resource.GetContentData() 23 | } 24 | 25 | etag, _ := resource.GetEtag() 26 | lastm, _ := resource.GetLastModified(http.TimeFormat) 27 | ctype, _ := resource.GetContentType() 28 | 29 | gh.response.SetHeader("ETag", etag). 30 | SetHeader("Last-Modified", lastm). 31 | SetHeader("Content-Type", ctype). 32 | Set(http.StatusOK, response) 33 | 34 | return gh.response 35 | } 36 | -------------------------------------------------------------------------------- /handlers/headers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | const ( 8 | HD_DEPTH = "Depth" 9 | HD_DEPTH_DEEP = "1" 10 | HD_PREFER = "Prefer" 11 | HD_PREFER_MINIMAL = "return=minimal" 12 | HD_PREFERENCE_APPLIED = "Preference-Applied" 13 | ) 14 | 15 | type headers struct { 16 | http.Header 17 | } 18 | 19 | func (h headers) IsDeep() bool { 20 | depth := h.Get(HD_DEPTH) 21 | return (depth == HD_DEPTH_DEEP) 22 | } 23 | 24 | func (h headers) IsMinimal() bool { 25 | prefer := h.Get(HD_PREFER) 26 | return (prefer == HD_PREFER_MINIMAL) 27 | } 28 | -------------------------------------------------------------------------------- /handlers/multistatus.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "github.com/samedi/caldav-go/data" 7 | "github.com/samedi/caldav-go/global" 8 | "github.com/samedi/caldav-go/ixml" 9 | "github.com/samedi/caldav-go/lib" 10 | "net/http" 11 | ) 12 | 13 | // Wraps a multistatus response. It contains the set of `Responses` 14 | // that will serve to build the final XML. Multistatus responses are 15 | // used by the REPORT and PROPFIND methods. 16 | type multistatusResp struct { 17 | // The set of multistatus responses used to build each of the nodes. 18 | Responses []msResponse 19 | // Flag that XML should be minimal or not 20 | // [defined in the draft https://tools.ietf.org/html/draft-murchison-webdav-prefer-05] 21 | Minimal bool 22 | } 23 | 24 | type msResponse struct { 25 | Href string 26 | Found bool 27 | Propstats msPropstats 28 | } 29 | 30 | type msPropstats map[int]msProps 31 | 32 | // Adds a msProp to the map with the key being the prop status. 33 | func (stats msPropstats) Add(prop msProp) { 34 | stats[prop.Status] = append(stats[prop.Status], prop) 35 | } 36 | 37 | func (stats msPropstats) Clone() msPropstats { 38 | clone := make(msPropstats) 39 | 40 | for k, v := range stats { 41 | clone[k] = v 42 | } 43 | 44 | return clone 45 | } 46 | 47 | type msProps []msProp 48 | 49 | type msProp struct { 50 | Tag xml.Name 51 | Content string 52 | Contents []string 53 | Status int 54 | } 55 | 56 | // Function that processes all the required props for a given resource. 57 | // ## Params 58 | // resource: the target calendar resource. 59 | // reqprops: set of required props that must be processed for the resource. 60 | // ## Returns 61 | // The set of props (msProp) processed. Each prop is mapped to a HTTP status code. 62 | // So if a prop is found and processed ok, it'll be mapped to 200. If it's not found, 63 | // it'll be mapped to 404, and so on. 64 | func (ms *multistatusResp) Propstats(resource *data.Resource, reqprops []xml.Name) msPropstats { 65 | if resource == nil { 66 | return nil 67 | } 68 | 69 | result := make(msPropstats) 70 | 71 | for _, ptag := range reqprops { 72 | pvalue := msProp{ 73 | Tag: ptag, 74 | Status: http.StatusOK, 75 | } 76 | 77 | pfound := false 78 | switch ptag { 79 | case ixml.CALENDAR_DATA_TG: 80 | pvalue.Content, pfound = resource.GetContentData() 81 | if pfound { 82 | pvalue.Content = ixml.EscapeText(pvalue.Content) 83 | } 84 | case ixml.GET_ETAG_TG: 85 | pvalue.Content, pfound = resource.GetEtag() 86 | case ixml.GET_CONTENT_TYPE_TG: 87 | pvalue.Content, pfound = resource.GetContentType() 88 | case ixml.GET_CONTENT_LENGTH_TG: 89 | pvalue.Content, pfound = resource.GetContentLength() 90 | case ixml.DISPLAY_NAME_TG: 91 | pvalue.Content, pfound = resource.GetDisplayName() 92 | if pfound { 93 | pvalue.Content = ixml.EscapeText(pvalue.Content) 94 | } 95 | case ixml.GET_LAST_MODIFIED_TG: 96 | pvalue.Content, pfound = resource.GetLastModified(http.TimeFormat) 97 | case ixml.OWNER_TG: 98 | pvalue.Content, pfound = resource.GetOwnerPath() 99 | case ixml.GET_CTAG_TG: 100 | pvalue.Content, pfound = resource.GetEtag() 101 | case ixml.PRINCIPAL_URL_TG, 102 | ixml.PRINCIPAL_COLLECTION_SET_TG, 103 | ixml.CALENDAR_USER_ADDRESS_SET_TG, 104 | ixml.CALENDAR_HOME_SET_TG: 105 | pvalue.Content, pfound = ixml.HrefTag(resource.Path), true 106 | case ixml.RESOURCE_TYPE_TG: 107 | if resource.IsCollection() { 108 | pvalue.Content, pfound = ixml.Tag(ixml.COLLECTION_TG, "")+ixml.Tag(ixml.CALENDAR_TG, ""), true 109 | 110 | if resource.IsPrincipal() { 111 | pvalue.Content += ixml.Tag(ixml.PRINCIPAL_TG, "") 112 | } 113 | } else { 114 | // resourcetype must be returned empty for non-collection elements 115 | pvalue.Content, pfound = "", true 116 | } 117 | case ixml.CURRENT_USER_PRINCIPAL_TG: 118 | if global.User != nil { 119 | path := fmt.Sprintf("/%s/", global.User.Name) 120 | pvalue.Content, pfound = ixml.HrefTag(path), true 121 | } 122 | case ixml.SUPPORTED_CALENDAR_COMPONENT_SET_TG: 123 | if resource.IsCollection() { 124 | for _, component := range global.SupportedComponents { 125 | // TODO: use ixml somehow to build the below tag 126 | compTag := fmt.Sprintf(``, component) 127 | pvalue.Contents = append(pvalue.Contents, compTag) 128 | } 129 | pfound = true 130 | } 131 | } 132 | 133 | if !pfound { 134 | pvalue.Status = http.StatusNotFound 135 | } 136 | 137 | result.Add(pvalue) 138 | } 139 | 140 | return result 141 | } 142 | 143 | // Adds a new `msResponse` to the `Responses` array. 144 | func (ms *multistatusResp) AddResponse(href string, found bool, propstats msPropstats) { 145 | ms.Responses = append(ms.Responses, msResponse{ 146 | Href: href, 147 | Found: found, 148 | Propstats: propstats, 149 | }) 150 | } 151 | 152 | func (ms *multistatusResp) ToXML() string { 153 | // init multistatus 154 | var bf lib.StringBuffer 155 | bf.Write(``) 156 | bf.Write(``, ixml.Namespaces()) 157 | 158 | // iterate over event hrefs and build multistatus XML on the fly 159 | for _, response := range ms.Responses { 160 | bf.Write("") 161 | bf.Write(ixml.HrefTag(response.Href)) 162 | 163 | if response.Found { 164 | propstats := response.Propstats.Clone() 165 | 166 | if ms.Minimal { 167 | delete(propstats, http.StatusNotFound) 168 | 169 | if len(propstats) == 0 { 170 | bf.Write("") 171 | bf.Write("") 172 | bf.Write(ixml.StatusTag(http.StatusOK)) 173 | bf.Write("") 174 | bf.Write("") 175 | 176 | continue 177 | } 178 | } 179 | 180 | for status, props := range propstats { 181 | bf.Write("") 182 | bf.Write("") 183 | for _, prop := range props { 184 | bf.Write(ms.propToXML(prop)) 185 | } 186 | bf.Write("") 187 | bf.Write(ixml.StatusTag(status)) 188 | bf.Write("") 189 | } 190 | } else { 191 | // if does not find the resource set 404 192 | bf.Write(ixml.StatusTag(http.StatusNotFound)) 193 | } 194 | bf.Write("") 195 | } 196 | bf.Write("") 197 | 198 | return bf.String() 199 | } 200 | 201 | func (ms *multistatusResp) propToXML(prop msProp) string { 202 | for _, content := range prop.Contents { 203 | prop.Content += content 204 | } 205 | xmlString := ixml.Tag(prop.Tag, prop.Content) 206 | return xmlString 207 | } 208 | -------------------------------------------------------------------------------- /handlers/multistatus_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/xml" 5 | "testing" 6 | 7 | "github.com/samedi/caldav-go/test" 8 | ) 9 | 10 | // Tests the XML serialization when the option to return a minimal content is set or not. 11 | func TestToXML(t *testing.T) { 12 | ms := new(multistatusResp) 13 | ms.Responses = append(ms.Responses, msResponse{ 14 | Href: "/123", 15 | Found: true, 16 | Propstats: msPropstats{ 17 | 200: msProps{ 18 | msProp{Tag: xml.Name{Local: "getetag"}}, 19 | }, 20 | 404: msProps{ 21 | msProp{Tag: xml.Name{Local: "owner"}}, 22 | }, 23 | }, 24 | }) 25 | 26 | // First test when the minimal flag is false. It should return 27 | // all serialize props, including the ones not found 28 | 29 | ms.Minimal = false 30 | expected := ` 31 | 32 | 33 | 34 | /123 35 | 36 | 37 | 38 | 39 | HTTP/1.1 200 OK 40 | 41 | 42 | 43 | 44 | 45 | HTTP/1.1 404 Not Found 46 | 47 | 48 | ` 49 | 50 | test.AssertMultistatusXML(ms.ToXML(), expected, t) 51 | 52 | // Now test when the minimal flag is true. It should omit 53 | // all props that were not found 54 | 55 | ms.Minimal = true 56 | expected = ` 57 | 58 | 59 | 60 | /123 61 | 62 | 63 | 64 | 65 | HTTP/1.1 200 OK 66 | 67 | 68 | ` 69 | 70 | test.AssertMultistatusXML(ms.ToXML(), expected, t) 71 | 72 | // adding this just to make sure that the following test does not affect the other DAV:responses 73 | ms.Responses = append(ms.Responses, msResponse{ 74 | Href: "/456", 75 | Found: false, 76 | }) 77 | 78 | // If in the propstats there are only not found props, then instead of having an empty 79 | // node, the expected should be as the below. 80 | 81 | expected = ` 82 | 83 | 84 | 85 | /123 86 | 87 | 88 | HTTP/1.1 200 OK 89 | 90 | 91 | 92 | /456 93 | HTTP/1.1 404 Not Found 94 | 95 | ` 96 | 97 | delete(ms.Responses[0].Propstats, 200) 98 | 99 | test.AssertMultistatusXML(ms.ToXML(), expected, t) 100 | } 101 | -------------------------------------------------------------------------------- /handlers/not_implemented.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type notImplementedHandler struct { 8 | handlerData 9 | } 10 | 11 | func (h notImplementedHandler) Handle() *Response { 12 | return h.response.Set(http.StatusNotImplemented, "") 13 | } 14 | -------------------------------------------------------------------------------- /handlers/options.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type optionsHandler struct { 8 | handlerData 9 | } 10 | 11 | // Returns the allowed methods and the DAV features implemented by the current server. 12 | // For more information about the values and format read RFC4918 Sections 10.1 and 18. 13 | func (oh optionsHandler) Handle() *Response { 14 | // Set the DAV compliance header: 15 | // 1: Server supports all the requirements specified in RFC2518 16 | // 3: Server supports all the revisions specified in RFC4918 17 | // calendar-access: Server supports all the extensions specified in RFC4791 18 | oh.response.SetHeader("DAV", "1, 3, calendar-access"). 19 | SetHeader("Allow", "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, REPORT"). 20 | Set(http.StatusOK, "") 21 | 22 | return oh.response 23 | } 24 | -------------------------------------------------------------------------------- /handlers/preconditions.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type requestPreconditions struct { 8 | request *http.Request 9 | } 10 | 11 | func (p *requestPreconditions) IfMatch(etag string) bool { 12 | etagMatch := p.request.Header["If-Match"] 13 | return len(etagMatch) == 0 || etagMatch[0] == "*" || etagMatch[0] == etag 14 | } 15 | 16 | func (p *requestPreconditions) IfMatchPresent() bool { 17 | return len(p.request.Header["If-Match"]) != 0 18 | } 19 | 20 | func (p *requestPreconditions) IfNoneMatch(value string) bool { 21 | valueMatch := p.request.Header["If-None-Match"] 22 | return len(valueMatch) == 1 && valueMatch[0] == value 23 | } 24 | -------------------------------------------------------------------------------- /handlers/propfind.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/xml" 5 | ) 6 | 7 | type propfindHandler struct { 8 | handlerData 9 | } 10 | 11 | func (ph propfindHandler) Handle() *Response { 12 | // get the target resources based on the request URL 13 | resources, err := ph.storage.GetResources(ph.requestPath, ph.headers.IsDeep()) 14 | if err != nil { 15 | return ph.response.SetError(err) 16 | } 17 | 18 | // read body string to xml struct 19 | type XMLProp2 struct { 20 | Tags []xml.Name `xml:",any"` 21 | } 22 | type XMLRoot2 struct { 23 | XMLName xml.Name 24 | Prop XMLProp2 `xml:"DAV: prop"` 25 | } 26 | var requestXML XMLRoot2 27 | xml.Unmarshal([]byte(ph.requestBody), &requestXML) 28 | 29 | multistatus := &multistatusResp{ 30 | Minimal: ph.headers.IsMinimal(), 31 | } 32 | // for each href, build the multistatus responses 33 | for _, resource := range resources { 34 | propstats := multistatus.Propstats(&resource, requestXML.Prop.Tags) 35 | multistatus.AddResponse(resource.Path, true, propstats) 36 | } 37 | 38 | if multistatus.Minimal { 39 | ph.response.SetHeader(HD_PREFERENCE_APPLIED, HD_PREFER_MINIMAL) 40 | } 41 | 42 | return ph.response.Set(207, multistatus.ToXML()) 43 | } 44 | -------------------------------------------------------------------------------- /handlers/put.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/samedi/caldav-go/errs" 5 | "net/http" 6 | ) 7 | 8 | type putHandler struct { 9 | handlerData 10 | } 11 | 12 | func (ph putHandler) Handle() *Response { 13 | precond := requestPreconditions{ph.request} 14 | success := false 15 | 16 | // check if resource exists 17 | resourcePath := ph.requestPath 18 | resource, found, err := ph.storage.GetShallowResource(resourcePath) 19 | if err != nil && err != errs.ResourceNotFoundError { 20 | return ph.response.SetError(err) 21 | } 22 | 23 | // PUT is allowed in 2 cases: 24 | // 25 | // 1. Item NOT FOUND and there is NO ETAG match header: CREATE a new item 26 | if !found && !precond.IfMatchPresent() { 27 | // create new event resource 28 | resource, err = ph.storage.CreateResource(resourcePath, ph.requestBody) 29 | if err != nil { 30 | return ph.response.SetError(err) 31 | } 32 | 33 | success = true 34 | } 35 | 36 | if found { 37 | // TODO: Handle PUT on collections 38 | if resource.IsCollection() { 39 | return ph.response.Set(http.StatusPreconditionFailed, "") 40 | } 41 | 42 | // 2. Item exists, the resource etag is verified and there's no IF-NONE-MATCH=* header: UPDATE the item 43 | resourceEtag, _ := resource.GetEtag() 44 | if found && precond.IfMatch(resourceEtag) && !precond.IfNoneMatch("*") { 45 | // update resource 46 | resource, err = ph.storage.UpdateResource(resourcePath, ph.requestBody) 47 | if err != nil { 48 | return ph.response.SetError(err) 49 | } 50 | 51 | success = true 52 | } 53 | } 54 | 55 | if !success { 56 | return ph.response.Set(http.StatusPreconditionFailed, "") 57 | } 58 | 59 | resourceEtag, _ := resource.GetEtag() 60 | return ph.response.SetHeader("ETag", resourceEtag). 61 | Set(http.StatusCreated, "") 62 | } 63 | -------------------------------------------------------------------------------- /handlers/report.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/samedi/caldav-go/data" 10 | "github.com/samedi/caldav-go/ixml" 11 | ) 12 | 13 | type reportHandler struct { 14 | handlerData 15 | } 16 | 17 | // See more at RFC4791#section-7.1 18 | func (rh reportHandler) Handle() *Response { 19 | urlResource, found, err := rh.storage.GetShallowResource(rh.requestPath) 20 | if !found { 21 | return rh.response.Set(http.StatusNotFound, "") 22 | } else if err != nil { 23 | return rh.response.SetError(err) 24 | } 25 | 26 | // read body string to xml struct 27 | var requestXML reportRootXML 28 | xml.Unmarshal([]byte(rh.requestBody), &requestXML) 29 | 30 | // The resources to be reported are fetched by the type of the request. If it is 31 | // a `calendar-multiget`, the resources come based on a set of `hrefs` in the request body. 32 | // If it is a `calendar-query`, the resources are calculated based on set of filters in the request. 33 | var resourcesToReport []reportRes 34 | switch requestXML.XMLName { 35 | case ixml.CALENDAR_MULTIGET_TG: 36 | resourcesToReport, err = rh.fetchResourcesByList(urlResource, requestXML.Hrefs) 37 | case ixml.CALENDAR_QUERY_TG: 38 | resourcesToReport, err = rh.fetchResourcesByFilters(urlResource, requestXML.Filters) 39 | default: 40 | return rh.response.Set(http.StatusPreconditionFailed, "") 41 | } 42 | 43 | if err != nil { 44 | return rh.response.SetError(err) 45 | } 46 | 47 | multistatus := &multistatusResp{ 48 | Minimal: rh.headers.IsMinimal(), 49 | } 50 | // for each href, build the multistatus responses 51 | for _, r := range resourcesToReport { 52 | propstats := multistatus.Propstats(r.resource, requestXML.Prop.Tags) 53 | multistatus.AddResponse(r.href, r.found, propstats) 54 | } 55 | 56 | if multistatus.Minimal { 57 | rh.response.SetHeader(HD_PREFERENCE_APPLIED, HD_PREFER_MINIMAL) 58 | } 59 | 60 | return rh.response.Set(207, multistatus.ToXML()) 61 | } 62 | 63 | type reportPropXML struct { 64 | Tags []xml.Name `xml:",any"` 65 | } 66 | 67 | type reportRootXML struct { 68 | XMLName xml.Name 69 | Prop reportPropXML `xml:"DAV: prop"` 70 | Hrefs []string `xml:"DAV: href"` 71 | Filters reportFilterXML `xml:"urn:ietf:params:xml:ns:caldav filter"` 72 | } 73 | 74 | type reportFilterXML struct { 75 | XMLName xml.Name 76 | InnerContent string `xml:",innerxml"` 77 | } 78 | 79 | func (rfXml reportFilterXML) toString() string { 80 | return fmt.Sprintf("<%s>%s", rfXml.XMLName.Local, rfXml.InnerContent, rfXml.XMLName.Local) 81 | } 82 | 83 | // Wraps a resource that has to be reported, either fetched by filters or by a list. 84 | // Basically it contains the original requested `href`, the actual `resource` (can be nil) 85 | // and if the `resource` was `found` or not 86 | type reportRes struct { 87 | href string 88 | resource *data.Resource 89 | found bool 90 | } 91 | 92 | // The resources are fetched based on the origin resource and a set of filters. 93 | // If the origin resource is a collection, the filters are checked against each of the collection's resources 94 | // to see if they match. The collection's resources that match the filters are returned. The ones that will be returned 95 | // are the resources that were not found (does not exist) and the ones that matched the filters. The ones that did not 96 | // match the filter will not appear in the response result. 97 | // If the origin resource is not a collection, the function just returns it and ignore any filter processing. 98 | // [See RFC4791#section-7.8] 99 | func (rh reportHandler) fetchResourcesByFilters(origin *data.Resource, filtersXML reportFilterXML) ([]reportRes, error) { 100 | // The list of resources that has to be reported back in the response. 101 | reps := []reportRes{} 102 | 103 | if origin.IsCollection() { 104 | filters, _ := data.ParseResourceFilters(filtersXML.toString()) 105 | resources, err := rh.storage.GetResourcesByFilters(origin.Path, filters) 106 | 107 | if err != nil { 108 | return reps, err 109 | } 110 | 111 | for in, resource := range resources { 112 | reps = append(reps, reportRes{resource.Path, &resources[in], true}) 113 | } 114 | } else { 115 | // the origin resource is not a collection, so returns just that as the result 116 | reps = append(reps, reportRes{origin.Path, origin, true}) 117 | } 118 | 119 | return reps, nil 120 | } 121 | 122 | // The hrefs can come from (1) the request URL or (2) from the request body itself. 123 | // If the origin resource from the URL points to a collection (2), we will check the request body 124 | // to get the requested `hrefs` (resource paths). Each requested href has to be related to the collection. 125 | // The ones that are not, we simply ignore them. 126 | // If the resource from the URL is NOT a collection (1) we process the the report only for this resource 127 | // and ignore any othre requested hrefs that might be present in the request body. 128 | // [See RFC4791#section-7.9] 129 | func (rh reportHandler) fetchResourcesByList(origin *data.Resource, requestedPaths []string) ([]reportRes, error) { 130 | reps := []reportRes{} 131 | 132 | if origin.IsCollection() { 133 | resources, err := rh.storage.GetResourcesByList(requestedPaths) 134 | 135 | if err != nil { 136 | return reps, err 137 | } 138 | 139 | // we put all the resources found in a map path -> resource. 140 | // this will be used later to query which requested resource was found 141 | // or not and mount the response 142 | resourcesMap := make(map[string]*data.Resource) 143 | for _, resource := range resources { 144 | r := resource 145 | resourcesMap[resource.Path] = &r 146 | } 147 | 148 | for _, requestedPath := range requestedPaths { 149 | // if the requested path does not belong to the origin collection, skip 150 | // ('belonging' means that the path's prefix is the same as the collection path) 151 | if !strings.HasPrefix(requestedPath, origin.Path) { 152 | continue 153 | } 154 | 155 | resource, found := resourcesMap[requestedPath] 156 | reps = append(reps, reportRes{requestedPath, resource, found}) 157 | } 158 | } else { 159 | reps = append(reps, reportRes{origin.Path, origin, true}) 160 | } 161 | 162 | return reps, nil 163 | } 164 | -------------------------------------------------------------------------------- /handlers/report_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/samedi/caldav-go/ixml" 9 | "github.com/samedi/caldav-go/test" 10 | ) 11 | 12 | // Test 1: when the URL path points to a collection and passing the list of hrefs in the body. 13 | func TestHandle1(t *testing.T) { 14 | stg := test.NewFakeStorage() 15 | r1Data := "BEGIN:VEVENT\nSUMMARY:Party\nEND:VEVENT" 16 | stg.AddFakeResource("/test-data/report/", "123-456-789.ics", r1Data) 17 | r2Data := "BEGIN:VEVENT\nSUMMARY:Watch movies\nEND:VEVENT" 18 | stg.AddFakeResource("/test-data/report/", "789-456-123.ics", r2Data) 19 | 20 | handler := reportHandler{ 21 | handlerData{ 22 | requestPath: "/test-data/report/", 23 | requestBody: ` 24 | 25 | 26 | 27 | 28 | 29 | 30 | /test-data/report/123-456-789.ics 31 | /foo/bar 32 | /test-data/report/789-456-123.ics 33 | /test-data/report/000-000-000.ics 34 | 35 | `, 36 | response: NewResponse(), 37 | storage: stg, 38 | }, 39 | } 40 | 41 | // The response should contain only the hrefs that belong to the collection. 42 | // the ones that do not belong are ignored. 43 | expectedRespBody := fmt.Sprintf(` 44 | 45 | 46 | 47 | /test-data/report/123-456-789.ics 48 | 49 | 50 | ? 51 | %s 52 | 53 | HTTP/1.1 200 OK 54 | 55 | 56 | 57 | /test-data/report/789-456-123.ics 58 | 59 | 60 | ? 61 | %s 62 | 63 | HTTP/1.1 200 OK 64 | 65 | 66 | 67 | /test-data/report/000-000-000.ics 68 | HTTP/1.1 404 Not Found 69 | 70 | 71 | `, ixml.EscapeText(r1Data), ixml.EscapeText(r2Data)) 72 | 73 | resp := handler.Handle() 74 | test.AssertMultistatusXML(resp.Body, expectedRespBody, t) 75 | } 76 | 77 | // Test 2: when the URL path points to an actual resource. 78 | func TestHandle2(t *testing.T) { 79 | stg := test.NewFakeStorage() 80 | r1Data := "BEGIN:VEVENT\nSUMMARY:Party\nEND:VEVENT" 81 | stg.AddFakeResource("/test-data/report/", "123-456-789.ics", r1Data) 82 | stg.AddFakeResource("/test-data/report/", "789-456-123.ics", "BEGIN:VEVENT\nSUMMARY:Watch movies\nEND:VEVENT") 83 | 84 | handler := reportHandler{ 85 | handlerData{ 86 | requestPath: "/test-data/report/123-456-789.ics", 87 | requestBody: ` 88 | 89 | 90 | 91 | 92 | 93 | 94 | /test-data/report/123-456-789.ics 95 | /foo/bar 96 | /test-data/report/789-456-123.ics 97 | /test-data/report/000-000-000.ics 98 | 99 | `, 100 | response: NewResponse(), 101 | storage: stg, 102 | }, 103 | } 104 | 105 | // The response should contain only the resource from the URL. The rest are ignored 106 | expectedRespBody := fmt.Sprintf(` 107 | 108 | 109 | 110 | /test-data/report/123-456-789.ics 111 | 112 | 113 | ? 114 | %s 115 | 116 | HTTP/1.1 200 OK 117 | 118 | 119 | 120 | `, ixml.EscapeText(r1Data)) 121 | 122 | resp := handler.Handle() 123 | test.AssertMultistatusXML(resp.Body, expectedRespBody, t) 124 | } 125 | 126 | // Test 3: when the URL points to a collection and passing filter rules in the body. 127 | func TestHandle3(t *testing.T) { 128 | stg := test.NewFakeStorage() 129 | stg.AddFakeResource("/test-data/report/", "volleyball.ics", "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Volleyball\nEND:VEVENT\nEND:VCALENDAR") 130 | r1Data := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Football\nEND:VEVENT\nEND:VCALENDAR" 131 | stg.AddFakeResource("/test-data/report/", "football.ics", r1Data) 132 | r2Data := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Footsteps\nEND:VEVENT\nEND:VCALENDAR" 133 | stg.AddFakeResource("/test-data/report/", "footsteps.ics", r2Data) 134 | 135 | handler := reportHandler{ 136 | handlerData{ 137 | requestPath: "/test-data/report/", 138 | requestBody: ` 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | FOO 150 | 151 | 152 | 153 | 154 | 155 | `, 156 | response: NewResponse(), 157 | storage: stg, 158 | }, 159 | } 160 | 161 | expectedRespBody := fmt.Sprintf(` 162 | 163 | 164 | 165 | /test-data/report/football.ics 166 | 167 | 168 | ? 169 | %s 170 | 171 | HTTP/1.1 200 OK 172 | 173 | 174 | 175 | /test-data/report/footsteps.ics 176 | 177 | 178 | ? 179 | %s 180 | 181 | HTTP/1.1 200 OK 182 | 183 | 184 | 185 | `, ixml.EscapeText(r1Data), ixml.EscapeText(r2Data)) 186 | 187 | resp := handler.Handle() 188 | test.AssertMultistatusXML(resp.Body, expectedRespBody, t) 189 | } 190 | 191 | // Test 4: when making a request with a `Prefer` header. 192 | func TestHandle4(t *testing.T) { 193 | httpHeader := http.Header{} 194 | httpHeader.Add("Prefer", "return=minimal") 195 | 196 | stg := test.NewFakeStorage() 197 | stg.AddFakeResource("/test-data/report/", "123-456-789.ics", "BEGIN:VEVENT\nSUMMARY:Party\nEND:VEVENT") 198 | 199 | handler := reportHandler{ 200 | handlerData{ 201 | requestPath: "/test-data/report/", 202 | requestBody: ` 203 | 204 | 205 | 206 | 207 | 208 | 209 | /test-data/report/123-456-789.ics 210 | 211 | `, 212 | headers: headers{httpHeader}, 213 | response: NewResponse(), 214 | storage: stg, 215 | }, 216 | } 217 | 218 | // The response should omit all the nodes with status 404. 219 | expectedRespBody := fmt.Sprintf(` 220 | 221 | 222 | 223 | /test-data/report/123-456-789.ics 224 | 225 | 226 | ? 227 | 228 | HTTP/1.1 200 OK 229 | 230 | 231 | `) 232 | 233 | resp := handler.Handle() 234 | test.AssertMultistatusXML(resp.Body, expectedRespBody, t) 235 | 236 | if test.AssertInt(len(resp.Header["Preference-Applied"]), 1, t) { 237 | test.AssertStr(resp.Header.Get("Preference-Applied"), "return=minimal", t) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /handlers/response.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/samedi/caldav-go/errs" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // Response represents the handled CalDAV response. Used this when one needs to proxy the generated 10 | // response before being sent back to the client. 11 | type Response struct { 12 | Status int 13 | Header http.Header 14 | Body string 15 | Error error 16 | } 17 | 18 | // NewResponse initializes a new response object. 19 | func NewResponse() *Response { 20 | return &Response{ 21 | Header: make(http.Header), 22 | } 23 | } 24 | 25 | // Set sets the the status and body of the response. 26 | func (r *Response) Set(status int, body string) *Response { 27 | r.Status = status 28 | r.Body = body 29 | 30 | return r 31 | } 32 | 33 | // SetHeader adds a header to the response. 34 | func (r *Response) SetHeader(key, value string) *Response { 35 | r.Header.Set(key, value) 36 | 37 | return r 38 | } 39 | 40 | // SetError sets the response as an error. It inflects the response status based on the provided error. 41 | func (r *Response) SetError(err error) *Response { 42 | r.Error = err 43 | 44 | switch err { 45 | case errs.ResourceNotFoundError: 46 | r.Status = http.StatusNotFound 47 | case errs.UnauthorizedError: 48 | r.Status = http.StatusUnauthorized 49 | case errs.ForbiddenError: 50 | r.Status = http.StatusForbidden 51 | default: 52 | r.Status = http.StatusInternalServerError 53 | } 54 | 55 | return r 56 | } 57 | 58 | // Write writes the response back to the client using the provided `ResponseWriter`. 59 | func (r *Response) Write(writer http.ResponseWriter) { 60 | if r.Error == errs.UnauthorizedError { 61 | r.SetHeader("WWW-Authenticate", `Basic realm="Restricted"`) 62 | } 63 | 64 | for key, values := range r.Header { 65 | for _, value := range values { 66 | writer.Header().Set(key, value) 67 | } 68 | } 69 | 70 | writer.WriteHeader(r.Status) 71 | io.WriteString(writer, r.Body) 72 | } 73 | -------------------------------------------------------------------------------- /handlers/shared.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | // This function reads the request body and restore its content, so that 10 | // the request body can be read a second time. 11 | func readRequestBody(request *http.Request) string { 12 | // Read the content 13 | body, _ := ioutil.ReadAll(request.Body) 14 | // Restore the io.ReadCloser to its original state 15 | request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) 16 | // Use the content 17 | return string(body) 18 | } 19 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package caldav 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/samedi/caldav-go/ixml" 13 | "github.com/samedi/caldav-go/test" 14 | ) 15 | 16 | // ============= TESTS ====================== 17 | 18 | func TestMain(m *testing.M) { 19 | go startServer() 20 | 21 | // wait for the server to be started 22 | time.Sleep(time.Second / 3) 23 | os.Exit(m.Run()) 24 | } 25 | 26 | const ( 27 | TEST_SERVER_PORT = "8001" 28 | ) 29 | 30 | func startServer() { 31 | http.HandleFunc("/", RequestHandler) 32 | http.ListenAndServe(":"+TEST_SERVER_PORT, nil) 33 | } 34 | 35 | func TestOPTIONS(t *testing.T) { 36 | resp := doRequest("OPTIONS", "/test-data/", "", nil) 37 | 38 | if test.AssertInt(len(resp.Header["Allow"]), 1, t) { 39 | test.AssertStr(resp.Header["Allow"][0], "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, REPORT", t) 40 | } 41 | 42 | if test.AssertInt(len(resp.Header["Dav"]), 1, t) { 43 | test.AssertStr(resp.Header["Dav"][0], "1, 3, calendar-access", t) 44 | } 45 | 46 | test.AssertInt(resp.StatusCode, http.StatusOK, t) 47 | } 48 | 49 | func TestGET(t *testing.T) { 50 | collection := "/test-data/get/" 51 | rName := "123-456-789.ics" 52 | rPath := collection + rName 53 | rData := "BEGIN:VEVENT; SUMMARY:Party; END:VEVENT" 54 | createResource(collection, rName, rData) 55 | 56 | resp := doRequest("GET", rPath, "", nil) 57 | body := readResponseBody(resp) 58 | 59 | test.AssertInt(len(resp.Header["Etag"]), 1, t) 60 | test.AssertInt(len(resp.Header["Last-Modified"]), 1, t) 61 | test.AssertInt(len(resp.Header["Content-Type"]), 1, t) 62 | test.AssertStr(resp.Header["Content-Type"][0], "text/calendar; component=vcalendar", t) 63 | test.AssertStr(body, rData, t) 64 | test.AssertInt(resp.StatusCode, http.StatusOK, t) 65 | } 66 | 67 | func TestPUT(t *testing.T) { 68 | rpath := "/test-data/put/123-456-789.ics" 69 | 70 | // test when trying to create a new resource and a IF-MATCH header is present 71 | headers := map[string]string{ 72 | "If-Match": "1111111111111", 73 | } 74 | resp := doRequest("PUT", rpath, "", headers) 75 | test.AssertInt(resp.StatusCode, http.StatusPreconditionFailed, t) 76 | test.AssertResourceDoesNotExist(rpath, t) 77 | 78 | // test when trying to create a new resource (no headers this time) 79 | resourceData := "BEGIN:VEVENT; SUMMARY:Lunch; END:VEVENT" 80 | resp = doRequest("PUT", rpath, resourceData, nil) 81 | test.AssertInt(resp.StatusCode, http.StatusCreated, t) 82 | if !test.AssertInt(len(resp.Header["Etag"]), 1, t) { 83 | return 84 | } 85 | etag := resp.Header["Etag"][0] 86 | test.AssertResourceExists(rpath, t) 87 | test.AssertResourceData(rpath, resourceData, t) 88 | 89 | // test when trying to update a collection (folder) 90 | resp = doRequest("PUT", "/test-data/put/", "", nil) 91 | test.AssertInt(resp.StatusCode, http.StatusPreconditionFailed, t) 92 | 93 | // test when trying to update the resource but the ETag check (IF-MATCH header) does not match 94 | originalData := resourceData 95 | updatedData := "BEGIN:VEVENT; SUMMARY:Meeting; END:VEVENT" 96 | resp = doRequest("PUT", rpath, updatedData, headers) 97 | test.AssertInt(resp.StatusCode, http.StatusPreconditionFailed, t) 98 | test.AssertResourceData(rpath, originalData, t) 99 | 100 | // test when trying to update the resource with the correct ETag check 101 | headers["If-Match"] = etag 102 | resp = doRequest("PUT", rpath, updatedData, headers) 103 | test.AssertInt(resp.StatusCode, http.StatusCreated, t) 104 | test.AssertResourceData(rpath, updatedData, t) 105 | 106 | // test when trying to force update the resource by not passing any ETag check 107 | originalData = updatedData 108 | updatedData = "BEGIN:VEVENT; SUMMARY:Gym; END:VEVENT" 109 | delete(headers, "If-Match") 110 | resp = doRequest("PUT", rpath, updatedData, headers) 111 | test.AssertInt(resp.StatusCode, http.StatusCreated, t) 112 | test.AssertResourceData(rpath, updatedData, t) 113 | 114 | // test when trying to update the resource but there is a IF-NONE-MATCH=* 115 | originalData = updatedData 116 | updatedData = "BEGIN:VEVENT; SUMMARY:Party; END:VEVENT" 117 | headers["If-None-Match"] = "*" 118 | resp = doRequest("PUT", rpath, updatedData, headers) 119 | test.AssertInt(resp.StatusCode, http.StatusPreconditionFailed, t) 120 | test.AssertResourceData(rpath, originalData, t) 121 | } 122 | 123 | func TestDELETE(t *testing.T) { 124 | collection := "/test-data/delete/" 125 | rName := "123-456-789.ics" 126 | rpath := collection + rName 127 | createResource(collection, rName, "BEGIN:VEVENT; SUMMARY:Party; END:VEVENT") 128 | 129 | // test deleting a resource that does not exist 130 | resp := doRequest("DELETE", "/foo/bar", "", nil) 131 | test.AssertInt(resp.StatusCode, http.StatusNotFound, t) 132 | 133 | // test deleting a collection (folder) 134 | resp = doRequest("DELETE", collection, "", nil) 135 | test.AssertInt(resp.StatusCode, http.StatusMethodNotAllowed, t) 136 | test.AssertResourceExists(rpath, t) 137 | 138 | // test trying deleting when ETag check fails 139 | headers := map[string]string{ 140 | "If-Match": "1111111111111", 141 | } 142 | resp = doRequest("DELETE", rpath, "", headers) 143 | test.AssertInt(resp.StatusCode, http.StatusPreconditionFailed, t) 144 | test.AssertResourceExists(rpath, t) 145 | 146 | // test finally deleting the resource 147 | resp = doRequest("DELETE", rpath, "", nil) 148 | test.AssertInt(resp.StatusCode, http.StatusNoContent, t) 149 | test.AssertResourceDoesNotExist(rpath, t) 150 | } 151 | 152 | func TestPROPFIND(t *testing.T) { 153 | // test when resource does not exist 154 | resp := doRequest("PROPFIND", "/foo/bar/", "", nil) 155 | test.AssertInt(resp.StatusCode, http.StatusNotFound, t) 156 | 157 | collection := "/test-data/propfind/" 158 | rName := "123-456-789.ics" 159 | rpath := collection + rName 160 | createResource(collection, rName, "BEGIN:VEVENT; SUMMARY:Party; END:VEVENT") 161 | 162 | currentUser := "foo-bar-baz" 163 | SetupUser(currentUser) 164 | 165 | // Next test will check for properties that have been found for the resource 166 | 167 | propfindXML := ` 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | ` 187 | expectedRespBody := fmt.Sprintf(` 188 | 189 | 190 | 191 | /test-data/propfind/123-456-789.ics 192 | 193 | 194 | ? 195 | text/calendar; component=vcalendar 196 | 39 197 | 123-456-789.ics 198 | ? 199 | /test-data/ 200 | ? 201 | 202 | /test-data/propfind/123-456-789.ics 203 | 204 | 205 | /test-data/propfind/123-456-789.ics 206 | 207 | 208 | /test-data/propfind/123-456-789.ics 209 | 210 | 211 | /test-data/propfind/123-456-789.ics 212 | 213 | 214 | 215 | /%s/ 216 | 217 | 218 | HTTP/1.1 200 OK 219 | 220 | 221 | 222 | `, currentUser) 223 | 224 | resp = doRequest("PROPFIND", rpath, propfindXML, nil) 225 | respBody := readResponseBody(resp) 226 | test.AssertInt(resp.StatusCode, 207, t) 227 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 228 | 229 | // Next test will check for properties that have not been found for the resource 230 | 231 | propfindXML = ` 232 | 233 | 234 | 235 | 236 | 237 | 238 | ` 239 | expectedRespBody = fmt.Sprintf(` 240 | 241 | 242 | 243 | /test-data/propfind/123-456-789.ics 244 | 245 | 246 | 247 | 248 | HTTP/1.1 404 Not Found 249 | 250 | 251 | 252 | `) 253 | 254 | resp = doRequest("PROPFIND", rpath, propfindXML, nil) 255 | respBody = readResponseBody(resp) 256 | test.AssertInt(resp.StatusCode, 207, t) 257 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 258 | 259 | // Next test will check a request with the `Prefer` header 260 | 261 | headers := make(map[string]string) 262 | headers["Prefer"] = "return=minimal" 263 | 264 | propfindXML = ` 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | ` 273 | 274 | // the response should omit all the nodes with status 404. 275 | expectedRespBody = fmt.Sprintf(` 276 | 277 | 278 | 279 | /test-data/propfind/123-456-789.ics 280 | 281 | 282 | ? 283 | 284 | HTTP/1.1 200 OK 285 | 286 | 287 | 288 | `) 289 | 290 | resp = doRequest("PROPFIND", rpath, propfindXML, headers) 291 | respBody = readResponseBody(resp) 292 | test.AssertInt(resp.StatusCode, 207, t) 293 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 294 | if test.AssertInt(len(resp.Header["Preference-Applied"]), 1, t) { 295 | test.AssertStr(resp.Header.Get("Preference-Applied"), "return=minimal", t) 296 | } 297 | 298 | // Next tests will check request with the `Depth` header 299 | 300 | headers = make(map[string]string) 301 | 302 | propfindXML = ` 303 | 304 | 305 | 306 | 307 | 308 | 309 | ` 310 | 311 | // test PROPFIND with depth 0 312 | headers["Depth"] = "0" 313 | 314 | expectedRespBody = ` 315 | 316 | 317 | 318 | /test-data/propfind 319 | 320 | 321 | text/calendar 322 | 323 | HTTP/1.1 200 OK 324 | 325 | 326 | 327 | ` 328 | 329 | resp = doRequest("PROPFIND", "/test-data/propfind/", propfindXML, headers) 330 | respBody = readResponseBody(resp) 331 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 332 | 333 | // test PROPFIND with depth 1 334 | headers["Depth"] = "1" 335 | 336 | expectedRespBody = ` 337 | 338 | 339 | 340 | /test-data/propfind 341 | 342 | 343 | text/calendar 344 | 345 | HTTP/1.1 200 OK 346 | 347 | 348 | 349 | /test-data/propfind/123-456-789.ics 350 | 351 | 352 | text/calendar; component=vcalendar 353 | 354 | HTTP/1.1 200 OK 355 | 356 | 357 | 358 | ` 359 | 360 | resp = doRequest("PROPFIND", "/test-data/propfind/", propfindXML, headers) 361 | respBody = readResponseBody(resp) 362 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 363 | 364 | // the same test as before but without the trailing '/' on the collection's path 365 | resp = doRequest("PROPFIND", "/test-data/propfind", propfindXML, headers) 366 | respBody = readResponseBody(resp) 367 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 368 | } 369 | 370 | func TestREPORT(t *testing.T) { 371 | createResource("/test-data/report/", "123-456-789.ics", "BEGIN:VEVENT\nSUMMARY:Party\nEND:VEVENT") 372 | 373 | reportXML := ` 374 | 375 | 376 | 377 | 378 | 379 | 380 | /test-data/report/123-456-789.ics 381 | 382 | ` 383 | 384 | expectedRespBody := fmt.Sprintf(` 385 | 386 | 387 | 388 | /test-data/report/123-456-789.ics 389 | 390 | 391 | ? 392 | %s 393 | 394 | HTTP/1.1 200 OK 395 | 396 | 397 | 398 | `, ixml.EscapeText("BEGIN:VEVENT\nSUMMARY:Party\nEND:VEVENT")) 399 | 400 | resp := doRequest("REPORT", "/test-data/report/", reportXML, nil) 401 | respBody := readResponseBody(resp) 402 | test.AssertMultistatusXML(respBody, expectedRespBody, t) 403 | } 404 | 405 | // ================ FUNCS ======================== 406 | 407 | func doRequest(method, path, body string, headers map[string]string) *http.Response { 408 | client := &http.Client{} 409 | url := "http://localhost:" + TEST_SERVER_PORT + path 410 | req, err := http.NewRequest(method, url, strings.NewReader(body)) 411 | panicerr(err) 412 | for k, v := range headers { 413 | req.Header.Add(k, v) 414 | } 415 | resp, err := client.Do(req) 416 | panicerr(err) 417 | 418 | return resp 419 | } 420 | 421 | func readResponseBody(resp *http.Response) string { 422 | defer resp.Body.Close() 423 | body, err := ioutil.ReadAll(resp.Body) 424 | panicerr(err) 425 | 426 | return string(body) 427 | } 428 | 429 | func readResource(path string) string { 430 | pwd, _ := os.Getwd() 431 | data, err := ioutil.ReadFile(pwd + path) 432 | panicerr(err) 433 | 434 | return string(data) 435 | } 436 | 437 | func createResource(collection, rName, data string) { 438 | pwd, _ := os.Getwd() 439 | err := os.MkdirAll(pwd+collection, os.ModePerm) 440 | panicerr(err) 441 | f, err := os.Create(pwd + collection + rName) 442 | panicerr(err) 443 | f.WriteString(data) 444 | } 445 | 446 | func panicerr(err error) { 447 | if err != nil { 448 | panic(err) 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /ixml/ixml.go: -------------------------------------------------------------------------------- 1 | package ixml 2 | 3 | import ( 4 | "bytes" 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/samedi/caldav-go/lib" 10 | ) 11 | 12 | const ( 13 | DAV_NS = "DAV:" 14 | CALDAV_NS = "urn:ietf:params:xml:ns:caldav" 15 | CALSERV_NS = "http://calendarserver.org/ns/" 16 | ) 17 | 18 | var NS_PREFIXES = map[string]string{ 19 | DAV_NS: "D", 20 | CALDAV_NS: "C", 21 | CALSERV_NS: "CS", 22 | } 23 | 24 | var ( 25 | CALENDAR_TG = xml.Name{CALDAV_NS, "calendar"} 26 | CALENDAR_DATA_TG = xml.Name{CALDAV_NS, "calendar-data"} 27 | CALENDAR_HOME_SET_TG = xml.Name{CALDAV_NS, "calendar-home-set"} 28 | CALENDAR_QUERY_TG = xml.Name{CALDAV_NS, "calendar-query"} 29 | CALENDAR_MULTIGET_TG = xml.Name{CALDAV_NS, "calendar-multiget"} 30 | CALENDAR_USER_ADDRESS_SET_TG = xml.Name{CALDAV_NS, "calendar-user-address-set"} 31 | COLLECTION_TG = xml.Name{DAV_NS, "collection"} 32 | CURRENT_USER_PRINCIPAL_TG = xml.Name{DAV_NS, "current-user-principal"} 33 | DISPLAY_NAME_TG = xml.Name{DAV_NS, "displayname"} 34 | GET_CONTENT_LENGTH_TG = xml.Name{DAV_NS, "getcontentlength"} 35 | GET_CONTENT_TYPE_TG = xml.Name{DAV_NS, "getcontenttype"} 36 | GET_CTAG_TG = xml.Name{CALSERV_NS, "getctag"} 37 | GET_ETAG_TG = xml.Name{DAV_NS, "getetag"} 38 | GET_LAST_MODIFIED_TG = xml.Name{DAV_NS, "getlastmodified"} 39 | HREF_TG = xml.Name{DAV_NS, "href"} 40 | OWNER_TG = xml.Name{DAV_NS, "owner"} 41 | PRINCIPAL_TG = xml.Name{DAV_NS, "principal"} 42 | PRINCIPAL_COLLECTION_SET_TG = xml.Name{DAV_NS, "principal-collection-set"} 43 | PRINCIPAL_URL_TG = xml.Name{DAV_NS, "principal-URL"} 44 | RESOURCE_TYPE_TG = xml.Name{DAV_NS, "resourcetype"} 45 | STATUS_TG = xml.Name{DAV_NS, "status"} 46 | SUPPORTED_CALENDAR_COMPONENT_SET_TG = xml.Name{CALDAV_NS, "supported-calendar-component-set"} 47 | ) 48 | 49 | // Namespaces returns the default XML namespaces in for CalDAV contents. 50 | func Namespaces() string { 51 | bf := new(lib.StringBuffer) 52 | bf.Write(`xmlns:%s="%s" `, NS_PREFIXES[DAV_NS], DAV_NS) 53 | bf.Write(`xmlns:%s="%s" `, NS_PREFIXES[CALDAV_NS], CALDAV_NS) 54 | bf.Write(`xmlns:%s="%s"`, NS_PREFIXES[CALSERV_NS], CALSERV_NS) 55 | 56 | return bf.String() 57 | } 58 | 59 | // Tag returns a XML tag as string based on the given tag name and content. It 60 | // takes in consideration the namespace and also if it is an empty content or not. 61 | func Tag(xmlName xml.Name, content string) string { 62 | name := xmlName.Local 63 | ns := NS_PREFIXES[xmlName.Space] 64 | 65 | if ns != "" { 66 | ns = ns + ":" 67 | } 68 | 69 | if content != "" { 70 | return fmt.Sprintf("<%s%s>%s", ns, name, content, ns, name) 71 | } else { 72 | return fmt.Sprintf("<%s%s/>", ns, name) 73 | } 74 | } 75 | 76 | // HrefTag returns a DAV tag with the given href path. 77 | func HrefTag(href string) (tag string) { 78 | return Tag(HREF_TG, href) 79 | } 80 | 81 | // StatusTag returns a DAV tag with the given HTTP status. The 82 | // status is translated into a label, e.g.: HTTP/1.1 404 NotFound. 83 | func StatusTag(status int) string { 84 | statusText := fmt.Sprintf("HTTP/1.1 %d %s", status, http.StatusText(status)) 85 | return Tag(STATUS_TG, statusText) 86 | } 87 | 88 | // EscapeText escapes any special character in the given text and returns the result. 89 | func EscapeText(text string) string { 90 | buffer := bytes.NewBufferString("") 91 | xml.EscapeText(buffer, []byte(text)) 92 | 93 | return buffer.String() 94 | } 95 | -------------------------------------------------------------------------------- /lib/components.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | const ( 4 | VCALENDAR = "VCALENDAR" 5 | VEVENT = "VEVENT" 6 | VJOURNAL = "VJOURNAL" 7 | VTODO = "VTODO" 8 | ) 9 | -------------------------------------------------------------------------------- /lib/paths.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | func ToSlashPath(path string) string { 8 | cleanPath := filepath.Clean(path) 9 | return filepath.ToSlash(cleanPath) 10 | } 11 | -------------------------------------------------------------------------------- /lib/strbuff.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | type StringBuffer struct { 9 | buffer bytes.Buffer 10 | } 11 | 12 | func (b *StringBuffer) Write(format string, elem ...interface{}) { 13 | b.buffer.WriteString(fmt.Sprintf(format, elem...)) 14 | } 15 | 16 | func (b *StringBuffer) String() string { 17 | return b.buffer.String() 18 | } 19 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | go test -race ./... 4 | rm -rf test-data 5 | -------------------------------------------------------------------------------- /test/assertions.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "regexp" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/yosssi/gohtml" 13 | ) 14 | 15 | func AssertStr(target string, expectation string, t *testing.T) { 16 | if target != expectation { 17 | t.Error("Expected:", expectation, "| Got:", target, "\n ->", logFailedLine()) 18 | } 19 | } 20 | 21 | func AssertInt(target int, expectation int, t *testing.T) bool { 22 | if target != expectation { 23 | t.Error("Expected:", expectation, "| Got:", target, "\n ->", logFailedLine()) 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | 30 | func AssertResourceDoesNotExist(rpath string, t *testing.T) { 31 | pwd, _ := os.Getwd() 32 | if _, err := os.Stat(pwd + rpath); !os.IsNotExist(err) { 33 | t.Error("Resource", rpath, "exists", "\n ->", logFailedLine()) 34 | } 35 | } 36 | 37 | func AssertResourceExists(rpath string, t *testing.T) { 38 | pwd, _ := os.Getwd() 39 | _, err := os.Stat(pwd + rpath) 40 | if os.IsNotExist(err) { 41 | t.Error("Resource", rpath, "does not exist", "\n ->", logFailedLine()) 42 | } else { 43 | panicerr(err) 44 | } 45 | } 46 | 47 | func AssertResourceData(rpath, expectation string, t *testing.T) { 48 | pwd, _ := os.Getwd() 49 | data, err := ioutil.ReadFile(pwd + rpath) 50 | dataStr := string(data) 51 | panicerr(err) 52 | if dataStr != expectation { 53 | t.Error("Expected:", expectation, "| Got:", dataStr, "\n ->", logFailedLine()) 54 | } 55 | } 56 | 57 | func AssertMultistatusXML(target, expectation string, t *testing.T) { 58 | cleanXML := func(xml string) string { 59 | cleanupMap := map[string]string{ 60 | `\r?\n`: "", 61 | `>[\s|\t]+<`: "><", 62 | `[^<]+`: `?`, 63 | `[^<]+`: `?`, 64 | `[^<]+`: `?`, 65 | } 66 | 67 | for k, v := range cleanupMap { 68 | re := regexp.MustCompile(k) 69 | xml = re.ReplaceAllString(xml, v) 70 | } 71 | 72 | return strings.TrimSpace(xml) 73 | } 74 | 75 | target2 := cleanXML(target) 76 | expectation2 := cleanXML(expectation) 77 | 78 | if target2 != expectation2 { 79 | target3 := gohtml.Format(target2) 80 | expectation3 := gohtml.Format(expectation2) 81 | 82 | t.Error("\n== Expected XML ==\n\n", expectation3, "\n\n== Got XML ==\n\n", target3, "\n\n ->", logFailedLine()) 83 | } 84 | } 85 | 86 | func logFailedLine() string { 87 | pc, fn, line, _ := runtime.Caller(2) 88 | return fmt.Sprintf("Failed in %s[%s:%d]", runtime.FuncForPC(pc).Name(), fn, line) 89 | } 90 | -------------------------------------------------------------------------------- /test/errors.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | func panicerr(err error) { 4 | if err != nil { 5 | panic(err) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/resources.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/samedi/caldav-go/data" 7 | "github.com/samedi/caldav-go/global" 8 | ) 9 | 10 | // Creates a fake storage to be used in unit tests. 11 | // TODO: for now it's just creating a storage based of the default file storage. 12 | // Would be better having an in-memory storage instead and make use of a stubbed fake storage to make unit tests faster. 13 | func NewFakeStorage() FakeStorage { 14 | return FakeStorage{global.Storage} 15 | } 16 | 17 | type FakeStorage struct { 18 | data.Storage 19 | } 20 | 21 | func (s FakeStorage) AddFakeResource(collection, name, data string) { 22 | pwd, _ := os.Getwd() 23 | err := os.MkdirAll(pwd+collection, os.ModePerm) 24 | panicerr(err) 25 | f, err := os.Create(pwd + collection + name) 26 | panicerr(err) 27 | f.WriteString(data) 28 | } 29 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package caldav 2 | 3 | const ( 4 | VERSION = "3.0.0" 5 | ) 6 | --------------------------------------------------------------------------------