mirror of
https://github.com/1f349/go-webdav.git
synced 2024-12-22 08:14:15 +00:00
d7891ce50c
We were sometimes using TitleCase, sometimes Lowercase. Let's align on the idiomatic Go naming and pick TitleCase everywhere.
573 lines
17 KiB
Go
573 lines
17 KiB
Go
package caldav
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/emersion/go-ical"
|
|
"github.com/emersion/go-webdav"
|
|
"github.com/emersion/go-webdav/internal"
|
|
)
|
|
|
|
// TODO: add support for multiple calendars
|
|
|
|
// TODO if nothing more Caldav-specific needs to be added this should be merged with carddav.PutAddressObjectOptions
|
|
type PutCalendarObjectOptions struct {
|
|
// IfNoneMatch indicates that the client does not want to overwrite
|
|
// an existing resource.
|
|
IfNoneMatch bool
|
|
// IfMatch provides the ETag of the resource that the client intends
|
|
// to overwrite, can be ""
|
|
IfMatch string
|
|
}
|
|
|
|
// Backend is a CalDAV server backend.
|
|
type Backend interface {
|
|
CalendarHomeSetPath(ctx context.Context) (string, error)
|
|
Calendar(ctx context.Context) (*Calendar, error)
|
|
GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error)
|
|
ListCalendarObjects(ctx context.Context, req *CalendarCompRequest) ([]CalendarObject, error)
|
|
QueryCalendarObjects(ctx context.Context, query *CalendarQuery) ([]CalendarObject, error)
|
|
PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (loc string, err error)
|
|
|
|
webdav.UserPrincipalBackend
|
|
}
|
|
|
|
// Handler handles CalDAV HTTP requests. It can be used to create a CalDAV
|
|
// server.
|
|
type Handler struct {
|
|
Backend Backend
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if h.Backend == nil {
|
|
http.Error(w, "caldav: no backend available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/.well-known/caldav" {
|
|
principalPath, err := h.Backend.CurrentUserPrincipal(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "caldav: failed to determine current user principal", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, principalPath, http.StatusPermanentRedirect)
|
|
return
|
|
}
|
|
|
|
var err error
|
|
switch r.Method {
|
|
case "REPORT":
|
|
err = h.handleReport(w, r)
|
|
default:
|
|
b := backend{h.Backend}
|
|
hh := internal.Handler{&b}
|
|
hh.ServeHTTP(w, r)
|
|
}
|
|
|
|
if err != nil {
|
|
internal.ServeError(w, err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) error {
|
|
var report reportReq
|
|
if err := internal.DecodeXMLRequest(r, &report); err != nil {
|
|
return err
|
|
}
|
|
|
|
if report.Query != nil {
|
|
return h.handleQuery(r, w, report.Query)
|
|
} else if report.Multiget != nil {
|
|
return h.handleMultiget(r.Context(), w, report.Multiget)
|
|
}
|
|
return internal.HTTPErrorf(http.StatusBadRequest, "caldav: expected calendar-query or calendar-multiget element in REPORT request")
|
|
}
|
|
|
|
func decodeParamFilter(el *paramFilter) (*ParamFilter, error) {
|
|
pf := &ParamFilter{Name: el.Name}
|
|
if el.IsNotDefined != nil {
|
|
if el.TextMatch != nil {
|
|
return nil, fmt.Errorf("caldav: failed to parse param-filter: if is-not-defined is provided, text-match can't be provided")
|
|
}
|
|
pf.IsNotDefined = true
|
|
}
|
|
if el.TextMatch != nil {
|
|
pf.TextMatch = &TextMatch{Text: el.TextMatch.Text}
|
|
}
|
|
return pf, nil
|
|
}
|
|
|
|
func decodePropFilter(el *propFilter) (*PropFilter, error) {
|
|
pf := &PropFilter{Name: el.Name}
|
|
if el.IsNotDefined != nil {
|
|
if el.TextMatch != nil || el.TimeRange != nil || len(el.ParamFilter) > 0 {
|
|
return nil, fmt.Errorf("caldav: failed to parse prop-filter: if is-not-defined is provided, text-match, time-range, or param-filter can't be provided")
|
|
}
|
|
pf.IsNotDefined = true
|
|
}
|
|
if el.TextMatch != nil {
|
|
pf.TextMatch = &TextMatch{Text: el.TextMatch.Text}
|
|
}
|
|
if el.TimeRange != nil {
|
|
pf.Start = time.Time(el.TimeRange.Start)
|
|
pf.End = time.Time(el.TimeRange.End)
|
|
}
|
|
for _, paramEl := range el.ParamFilter {
|
|
paramFi, err := decodeParamFilter(¶mEl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pf.ParamFilter = append(pf.ParamFilter, *paramFi)
|
|
}
|
|
return pf, nil
|
|
}
|
|
|
|
func decodeCompFilter(el *compFilter) (*CompFilter, error) {
|
|
cf := &CompFilter{Name: el.Name}
|
|
if el.IsNotDefined != nil {
|
|
if el.TimeRange != nil || len(el.PropFilters) > 0 || len(el.CompFilters) > 0 {
|
|
return nil, fmt.Errorf("caldav: failed to parse comp-filter: if is-not-defined is provided, time-range, prop-filter, or comp-filter can't be provided")
|
|
}
|
|
cf.IsNotDefined = true
|
|
}
|
|
if el.TimeRange != nil {
|
|
cf.Start = time.Time(el.TimeRange.Start)
|
|
cf.End = time.Time(el.TimeRange.End)
|
|
}
|
|
for _, pfEl := range el.PropFilters {
|
|
pf, err := decodePropFilter(&pfEl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cf.Props = append(cf.Props, *pf)
|
|
}
|
|
for _, childEl := range el.CompFilters {
|
|
child, err := decodeCompFilter(&childEl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cf.Comps = append(cf.Comps, *child)
|
|
}
|
|
return cf, nil
|
|
}
|
|
|
|
func decodeComp(comp *comp) (*CalendarCompRequest, error) {
|
|
if comp == nil {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: unexpected empty calendar-data in request")
|
|
}
|
|
if comp.Allprop != nil && len(comp.Prop) > 0 {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: only one of allprop or prop can be specified in calendar-data")
|
|
}
|
|
if comp.Allcomp != nil && len(comp.Comp) > 0 {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: only one of allcomp or comp can be specified in calendar-data")
|
|
}
|
|
|
|
req := &CalendarCompRequest{
|
|
AllProps: comp.Allprop != nil,
|
|
AllComps: comp.Allcomp != nil,
|
|
}
|
|
for _, p := range comp.Prop {
|
|
req.Props = append(req.Props, p.Name)
|
|
}
|
|
for _, c := range comp.Comp {
|
|
comp, err := decodeComp(&c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Comps = append(req.Comps, *comp)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func decodeCalendarDataReq(calendarData *calendarDataReq) (*CalendarCompRequest, error) {
|
|
if calendarData.Comp == nil {
|
|
return &CalendarCompRequest{
|
|
AllProps: true,
|
|
AllComps: true,
|
|
}, nil
|
|
}
|
|
return decodeComp(calendarData.Comp)
|
|
}
|
|
|
|
func (h *Handler) handleQuery(r *http.Request, w http.ResponseWriter, query *calendarQuery) error {
|
|
var q CalendarQuery
|
|
// TODO: calendar-data in query.Prop
|
|
cf, err := decodeCompFilter(&query.Filter.CompFilter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q.CompFilter = *cf
|
|
|
|
cos, err := h.Backend.QueryCalendarObjects(r.Context(), &q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var resps []internal.Response
|
|
for _, co := range cos {
|
|
b := backend{h.Backend}
|
|
propfind := internal.PropFind{
|
|
Prop: query.Prop,
|
|
AllProp: query.AllProp,
|
|
PropName: query.PropName,
|
|
}
|
|
resp, err := b.propFindCalendarObject(r.Context(), &propfind, &co)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
|
|
ms := internal.NewMultiStatus(resps...)
|
|
|
|
return internal.ServeMultiStatus(w, ms)
|
|
}
|
|
|
|
func (h *Handler) handleMultiget(ctx context.Context, w http.ResponseWriter, multiget *calendarMultiget) error {
|
|
var dataReq CalendarCompRequest
|
|
if multiget.Prop != nil {
|
|
var calendarData calendarDataReq
|
|
if err := multiget.Prop.Decode(&calendarData); err != nil && !internal.IsNotFound(err) {
|
|
return err
|
|
}
|
|
decoded, err := decodeCalendarDataReq(&calendarData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dataReq = *decoded
|
|
}
|
|
|
|
var resps []internal.Response
|
|
for _, href := range multiget.Hrefs {
|
|
co, err := h.Backend.GetCalendarObject(ctx, href.Path, &dataReq)
|
|
if err != nil {
|
|
resp := internal.NewErrorResponse(href.Path, err)
|
|
resps = append(resps, *resp)
|
|
continue
|
|
}
|
|
|
|
b := backend{h.Backend}
|
|
propfind := internal.PropFind{
|
|
Prop: multiget.Prop,
|
|
AllProp: multiget.AllProp,
|
|
PropName: multiget.PropName,
|
|
}
|
|
resp, err := b.propFindCalendarObject(ctx, &propfind, co)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resps = append(resps, *resp)
|
|
}
|
|
|
|
ms := internal.NewMultiStatus(resps...)
|
|
return internal.ServeMultiStatus(w, ms)
|
|
}
|
|
|
|
type backend struct {
|
|
Backend Backend
|
|
}
|
|
|
|
func (b *backend) Options(r *http.Request) (caps []string, allow []string, err error) {
|
|
caps = []string{"calendar-access"}
|
|
|
|
homeSetPath, err := b.Backend.CalendarHomeSetPath(r.Context())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if r.URL.Path == "/" || r.URL.Path == principalPath || r.URL.Path == homeSetPath {
|
|
return caps, []string{http.MethodOptions, "PROPFIND", "REPORT", "DELETE", "MKCOL"}, nil
|
|
}
|
|
|
|
var dataReq CalendarCompRequest
|
|
_, err = b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
|
|
if httpErr, ok := err.(*internal.HTTPError); ok && httpErr.Code == http.StatusNotFound {
|
|
return caps, []string{http.MethodOptions, http.MethodPut}, nil
|
|
} else if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return caps, []string{
|
|
http.MethodOptions,
|
|
http.MethodHead,
|
|
http.MethodGet,
|
|
http.MethodPut,
|
|
http.MethodDelete,
|
|
"PROPFIND",
|
|
}, nil
|
|
}
|
|
|
|
func (b *backend) HeadGet(w http.ResponseWriter, r *http.Request) error {
|
|
var dataReq CalendarCompRequest
|
|
if r.Method != http.MethodHead {
|
|
dataReq.AllProps = true
|
|
}
|
|
co, err := b.Backend.GetCalendarObject(r.Context(), r.URL.Path, &dataReq)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w.Header().Set("Content-Type", ical.MIMEType)
|
|
if co.ContentLength > 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(co.ContentLength, 10))
|
|
}
|
|
if co.ETag != "" {
|
|
w.Header().Set("ETag", internal.ETag(co.ETag).String())
|
|
}
|
|
if !co.ModTime.IsZero() {
|
|
w.Header().Set("Last-Modified", co.ModTime.UTC().Format(http.TimeFormat))
|
|
}
|
|
|
|
if r.Method != http.MethodHead {
|
|
return ical.NewEncoder(w).Encode(co.Data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *backend) PropFind(r *http.Request, propfind *internal.PropFind, depth internal.Depth) (*internal.MultiStatus, error) {
|
|
homeSetPath, err := b.Backend.CalendarHomeSetPath(r.Context())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
principalPath, err := b.Backend.CurrentUserPrincipal(r.Context())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resps []internal.Response
|
|
|
|
if r.URL.Path == principalPath {
|
|
resp, err := b.propFindUserPrincipal(r.Context(), propfind, homeSetPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resps = append(resps, *resp)
|
|
} else if r.URL.Path == homeSetPath {
|
|
cal, err := b.Backend.Calendar(r.Context())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := b.propFindCalendar(r.Context(), propfind, cal)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resps = append(resps, *resp)
|
|
|
|
if depth != internal.DepthZero {
|
|
// TODO
|
|
}
|
|
} else {
|
|
// TODO
|
|
}
|
|
|
|
return internal.NewMultiStatus(resps...), nil
|
|
}
|
|
|
|
func (b *backend) propFindUserPrincipal(ctx context.Context, propfind *internal.PropFind, homeSetPath string) (*internal.Response, error) {
|
|
principalPath, err := b.Backend.CurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
props := map[xml.Name]internal.PropFindFunc{
|
|
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: principalPath}}, nil
|
|
},
|
|
calendarHomeSetName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &calendarHomeSet{Href: internal.Href{Path: homeSetPath}}, nil
|
|
},
|
|
}
|
|
return internal.NewPropFindResponse(principalPath, propfind, props)
|
|
}
|
|
|
|
func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropFind, cal *Calendar) (*internal.Response, error) {
|
|
props := map[xml.Name]internal.PropFindFunc{
|
|
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
path, err := b.Backend.CurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
|
|
},
|
|
internal.ResourceTypeName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return internal.NewResourceType(internal.CollectionName, calendarName), nil
|
|
},
|
|
internal.DisplayNameName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.DisplayName{Name: cal.Name}, nil
|
|
},
|
|
calendarDescriptionName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &calendarDescription{Description: cal.Description}, nil
|
|
},
|
|
supportedCalendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &supportedCalendarData{
|
|
Types: []calendarDataType{
|
|
{ContentType: ical.MIMEType, Version: "2.0"},
|
|
},
|
|
}, nil
|
|
},
|
|
supportedCalendarComponentSetName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &supportedCalendarComponentSet{
|
|
Comp: []comp{
|
|
{Name: ical.CompEvent},
|
|
},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
if cal.Description != "" {
|
|
props[calendarDescriptionName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &calendarDescription{Description: cal.Description}, nil
|
|
}
|
|
}
|
|
|
|
if cal.MaxResourceSize > 0 {
|
|
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &maxResourceSize{Size: cal.MaxResourceSize}, nil
|
|
}
|
|
}
|
|
|
|
// TODO: CALDAV:calendar-timezone, CALDAV:supported-calendar-component-set, CALDAV:min-date-time, CALDAV:max-date-time, CALDAV:max-instances, CALDAV:max-attendees-per-instance
|
|
|
|
return internal.NewPropFindResponse(cal.Path, propfind, props)
|
|
}
|
|
|
|
func (b *backend) propFindCalendarObject(ctx context.Context, propfind *internal.PropFind, co *CalendarObject) (*internal.Response, error) {
|
|
props := map[xml.Name]internal.PropFindFunc{
|
|
internal.CurrentUserPrincipalName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
path, err := b.Backend.CurrentUserPrincipal(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &internal.CurrentUserPrincipal{Href: internal.Href{Path: path}}, nil
|
|
},
|
|
internal.GetContentTypeName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetContentType{Type: ical.MIMEType}, nil
|
|
},
|
|
// TODO: address-data can only be used in REPORT requests
|
|
calendarDataName: func(*internal.RawXMLValue) (interface{}, error) {
|
|
var buf bytes.Buffer
|
|
if err := ical.NewEncoder(&buf).Encode(co.Data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &calendarDataResp{Data: buf.Bytes()}, nil
|
|
},
|
|
}
|
|
|
|
if co.ContentLength > 0 {
|
|
props[internal.GetContentLengthName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetContentLength{Length: co.ContentLength}, nil
|
|
}
|
|
}
|
|
if !co.ModTime.IsZero() {
|
|
props[internal.GetLastModifiedName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetLastModified{LastModified: internal.Time(co.ModTime)}, nil
|
|
}
|
|
}
|
|
|
|
if co.ETag != "" {
|
|
props[internal.GetETagName] = func(*internal.RawXMLValue) (interface{}, error) {
|
|
return &internal.GetETag{ETag: internal.ETag(co.ETag)}, nil
|
|
}
|
|
}
|
|
|
|
return internal.NewPropFindResponse(co.Path, propfind, props)
|
|
}
|
|
|
|
func (b *backend) PropPatch(r *http.Request, update *internal.PropertyUpdate) (*internal.Response, error) {
|
|
panic("TODO")
|
|
}
|
|
|
|
func (b *backend) Put(r *http.Request) (*internal.Href, error) {
|
|
if inm := r.Header.Get("If-None-Match"); inm != "" && inm != "*" {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "invalid value for If-None-Match header")
|
|
}
|
|
|
|
opts := PutCalendarObjectOptions{
|
|
IfNoneMatch: r.Header.Get("If-None-Match") == "*",
|
|
IfMatch: r.Header.Get("If-Match"),
|
|
}
|
|
|
|
t, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
if err != nil {
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: malformed Content-Type: %v", err)
|
|
}
|
|
if t != ical.MIMEType {
|
|
// TODO: send CALDAV:supported-calendar-data error
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: unsupported Content-Type %q", t)
|
|
}
|
|
|
|
// TODO: check CALDAV:max-resource-size precondition
|
|
cal, err := ical.NewDecoder(r.Body).Decode()
|
|
if err != nil {
|
|
// TODO: send CALDAV:valid-calendar-data error
|
|
return nil, internal.HTTPErrorf(http.StatusBadRequest, "caldav: failed to parse iCalendar: %v", err)
|
|
}
|
|
|
|
loc, err := b.Backend.PutCalendarObject(r.Context(), r.URL.Path, cal, &opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &internal.Href{Path: loc}, nil
|
|
}
|
|
|
|
func (b *backend) Delete(r *http.Request) error {
|
|
panic("TODO")
|
|
}
|
|
|
|
func (b *backend) Mkcol(r *http.Request) error {
|
|
panic("TODO")
|
|
}
|
|
|
|
func (b *backend) Copy(r *http.Request, dest *internal.Href, recursive, overwrite bool) (created bool, err error) {
|
|
panic("TODO")
|
|
}
|
|
|
|
func (b *backend) Move(r *http.Request, dest *internal.Href, overwrite bool) (created bool, err error) {
|
|
panic("TODO")
|
|
}
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1
|
|
type PreconditionType string
|
|
|
|
const (
|
|
PreconditionNoUIDConflict PreconditionType = "no-uid-conflict"
|
|
PreconditionSupportedCalendarData PreconditionType = "supported-calendar-data"
|
|
PreconditionSupportedCalendarComponent PreconditionType = "supported-calendar-component"
|
|
PreconditionValidCalendarData PreconditionType = "valid-calendar-data"
|
|
PreconditionValidCalendarObjectResource PreconditionType = "valid-calendar-object-resource"
|
|
PreconditionCalendarCollectionLocationOk PreconditionType = "calendar-collection-location-ok"
|
|
PreconditionMaxResourceSize PreconditionType = "max-resource-size"
|
|
PreconditionMinDateTime PreconditionType = "min-date-time"
|
|
PreconditionMaxDateTime PreconditionType = "max-date-time"
|
|
PreconditionMaxInstances PreconditionType = "max-instances"
|
|
PreconditionMaxAttendeesPerInstance PreconditionType = "max-attendees-per-instance"
|
|
)
|
|
|
|
func NewPreconditionError(err PreconditionType) error {
|
|
name := xml.Name{"urn:ietf:params:xml:ns:caldav", string(err)}
|
|
elem := internal.NewRawXMLElement(name, nil, nil)
|
|
return &internal.HTTPError{
|
|
Code: 409,
|
|
Err: &internal.Error{
|
|
Raw: []internal.RawXMLValue{*elem},
|
|
},
|
|
}
|
|
}
|