github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/nextcloud/nextcloud.go (about) 1 // Package nextcloud is a client library for NextCloud. It only supports files 2 // via Webdav for the moment. 3 package nextcloud 4 5 import ( 6 "encoding/json" 7 "io" 8 "net/http" 9 "net/url" 10 "path/filepath" 11 "runtime" 12 "strconv" 13 "time" 14 15 "github.com/cozy/cozy-stack/model/account" 16 "github.com/cozy/cozy-stack/model/instance" 17 "github.com/cozy/cozy-stack/model/vfs" 18 build "github.com/cozy/cozy-stack/pkg/config" 19 "github.com/cozy/cozy-stack/pkg/consts" 20 "github.com/cozy/cozy-stack/pkg/couchdb" 21 "github.com/cozy/cozy-stack/pkg/jsonapi" 22 "github.com/cozy/cozy-stack/pkg/safehttp" 23 "github.com/cozy/cozy-stack/pkg/webdav" 24 "github.com/labstack/echo/v4" 25 ) 26 27 type File struct { 28 DocID string `json:"id,omitempty"` 29 Type string `json:"type"` 30 Name string `json:"name"` 31 Size uint64 `json:"size,omitempty"` 32 Mime string `json:"mime,omitempty"` 33 Class string `json:"class,omitempty"` 34 UpdatedAt string `json:"updated_at,omitempty"` 35 ETag string `json:"etag,omitempty"` 36 url string 37 } 38 39 func (f *File) ID() string { return f.DocID } 40 func (f *File) Rev() string { return "" } 41 func (f *File) DocType() string { return consts.NextCloudFiles } 42 func (f *File) SetID(id string) { f.DocID = id } 43 func (f *File) SetRev(id string) {} 44 func (f *File) Clone() couchdb.Doc { panic("nextcloud.File should not be cloned") } 45 func (f *File) Included() []jsonapi.Object { return nil } 46 func (f *File) Relationships() jsonapi.RelationshipMap { return nil } 47 func (f *File) Links() *jsonapi.LinksList { 48 return &jsonapi.LinksList{ 49 Self: f.url, 50 } 51 } 52 53 var _ jsonapi.Object = (*File)(nil) 54 55 type NextCloud struct { 56 inst *instance.Instance 57 accountID string 58 webdav *webdav.Client 59 } 60 61 func New(inst *instance.Instance, accountID string) (*NextCloud, error) { 62 var doc couchdb.JSONDoc 63 err := couchdb.GetDoc(inst, consts.Accounts, accountID, &doc) 64 if err != nil { 65 if couchdb.IsNotFoundError(err) { 66 return nil, ErrAccountNotFound 67 } 68 return nil, err 69 } 70 account.Decrypt(doc) 71 72 if doc.M == nil || doc.M["account_type"] != "nextcloud" { 73 return nil, ErrInvalidAccount 74 } 75 auth, ok := doc.M["auth"].(map[string]interface{}) 76 if !ok { 77 return nil, ErrInvalidAccount 78 } 79 ncURL, _ := auth["url"].(string) 80 if ncURL == "" { 81 return nil, ErrInvalidAccount 82 } 83 u, err := url.Parse(ncURL) 84 if err != nil { 85 return nil, ErrInvalidAccount 86 } 87 username, _ := auth["login"].(string) 88 password, _ := auth["password"].(string) 89 logger := inst.Logger().WithNamespace("nextcloud") 90 webdav := &webdav.Client{ 91 Scheme: u.Scheme, 92 Host: u.Host, 93 Username: username, 94 Password: password, 95 Logger: logger, 96 } 97 nc := &NextCloud{ 98 inst: inst, 99 accountID: accountID, 100 webdav: webdav, 101 } 102 if err := nc.fillBasePath(&doc); err != nil { 103 return nil, err 104 } 105 return nc, nil 106 } 107 108 func (nc *NextCloud) Download(path string) (*webdav.Download, error) { 109 return nc.webdav.Get(path) 110 } 111 112 func (nc *NextCloud) Upload(path, mime string, body io.Reader) error { 113 headers := map[string]string{ 114 echo.HeaderContentType: mime, 115 } 116 return nc.webdav.Put(path, headers, body) 117 } 118 119 func (nc *NextCloud) Mkdir(path string) error { 120 return nc.webdav.Mkcol(path) 121 } 122 123 func (nc *NextCloud) Delete(path string) error { 124 return nc.webdav.Delete(path) 125 } 126 127 func (nc *NextCloud) Move(oldPath, newPath string) error { 128 return nc.webdav.Move(oldPath, newPath) 129 } 130 131 func (nc *NextCloud) Copy(oldPath, newPath string) error { 132 return nc.webdav.Copy(oldPath, newPath) 133 } 134 135 func (nc *NextCloud) ListFiles(path string) ([]jsonapi.Object, error) { 136 items, err := nc.webdav.List(path) 137 if err != nil { 138 return nil, err 139 } 140 141 var files []jsonapi.Object 142 for _, item := range items { 143 var mime, class string 144 if item.Type == "file" { 145 mime, class = vfs.ExtractMimeAndClassFromFilename(item.Name) 146 } 147 file := &File{ 148 DocID: item.ID, 149 Type: item.Type, 150 Name: item.Name, 151 Size: item.Size, 152 Mime: mime, 153 Class: class, 154 UpdatedAt: item.LastModified, 155 ETag: item.ETag, 156 url: nc.buildURL(item, path), 157 } 158 files = append(files, file) 159 } 160 return files, nil 161 } 162 163 func (nc *NextCloud) Downstream(path, dirID string, cozyMetadata *vfs.FilesCozyMetadata) (*vfs.FileDoc, error) { 164 dl, err := nc.webdav.Get(path) 165 if err != nil { 166 return nil, err 167 } 168 defer dl.Content.Close() 169 170 size, _ := strconv.Atoi(dl.Length) 171 mime, class := vfs.ExtractMimeAndClass(dl.Mime) 172 doc, err := vfs.NewFileDoc( 173 filepath.Base(path), 174 dirID, 175 int64(size), 176 nil, // md5sum 177 mime, 178 class, 179 time.Now(), 180 false, // executable 181 false, // trashed 182 false, // encrypted 183 nil, // tags 184 ) 185 if err != nil { 186 return nil, err 187 } 188 doc.CozyMetadata = cozyMetadata 189 190 fs := nc.inst.VFS() 191 file, err := fs.CreateFile(doc, nil) 192 if err != nil { 193 return nil, err 194 } 195 196 _, err = io.Copy(file, dl.Content) 197 if cerr := file.Close(); err == nil && cerr != nil { 198 return nil, cerr 199 } 200 if err != nil { 201 return nil, err 202 } 203 204 _ = nc.webdav.Delete(path) 205 return doc, nil 206 } 207 208 func (nc *NextCloud) Upstream(path, from string) error { 209 fs := nc.inst.VFS() 210 doc, err := fs.FileByID(from) 211 if err != nil { 212 return err 213 } 214 f, err := fs.OpenFile(doc) 215 if err != nil { 216 return err 217 } 218 defer f.Close() 219 220 headers := map[string]string{ 221 echo.HeaderContentType: doc.Mime, 222 echo.HeaderContentLength: strconv.Itoa(int(doc.ByteSize)), 223 } 224 if err := nc.webdav.Put(path, headers, f); err != nil { 225 return err 226 } 227 _ = fs.DestroyFile(doc) 228 return nil 229 } 230 231 func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error { 232 userID, _ := accountDoc.M["webdav_user_id"].(string) 233 if userID != "" { 234 nc.webdav.BasePath = "/remote.php/dav/files/" + userID 235 return nil 236 } 237 238 userID, err := nc.fetchUserID() 239 if err != nil { 240 return err 241 } 242 nc.webdav.BasePath = "/remote.php/dav/files/" + userID 243 244 // Try to persist the userID to avoid fetching it for every WebDAV request 245 accountDoc.M["webdav_user_id"] = userID 246 accountDoc.Type = consts.Accounts 247 account.Encrypt(*accountDoc) 248 _ = couchdb.UpdateDoc(nc.inst, accountDoc) 249 return nil 250 } 251 252 func (nc *NextCloud) buildURL(item webdav.Item, path string) string { 253 u := &url.URL{ 254 Scheme: nc.webdav.Scheme, 255 Host: nc.webdav.Host, 256 Path: "/apps/files/files/" + item.ID, 257 RawQuery: "dir=/" + path, 258 } 259 return u.String() 260 } 261 262 // https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html#fetch-your-own-status 263 func (nc *NextCloud) fetchUserID() (string, error) { 264 logger := nc.webdav.Logger 265 u := url.URL{ 266 Scheme: nc.webdav.Scheme, 267 Host: nc.webdav.Host, 268 User: url.UserPassword(nc.webdav.Username, nc.webdav.Password), 269 Path: "/ocs/v2.php/apps/user_status/api/v1/user_status", 270 } 271 req, err := http.NewRequest(http.MethodGet, u.String(), nil) 272 if err != nil { 273 return "", err 274 } 275 req.Header.Set("User-Agent", "cozy-stack "+build.Version+" ("+runtime.Version()+")") 276 req.Header.Set("OCS-APIRequest", "true") 277 req.Header.Set("Accept", "application/json") 278 start := time.Now() 279 res, err := safehttp.ClientWithKeepAlive.Do(req) 280 elapsed := time.Since(start) 281 if err != nil { 282 logger.Warnf("user_status %s: %s (%s)", u.Host, err, elapsed) 283 return "", err 284 } 285 defer res.Body.Close() 286 logger.Infof("user_status %s: %d (%s)", u.Host, res.StatusCode, elapsed) 287 if res.StatusCode != 200 { 288 return "", webdav.ErrInvalidAuth 289 } 290 var payload OCSPayload 291 if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { 292 logger.Warnf("cannot fetch NextCloud userID: %s", err) 293 return "", err 294 } 295 return payload.OCS.Data.UserID, nil 296 } 297 298 type OCSPayload struct { 299 OCS struct { 300 Data struct { 301 UserID string `json:"userId"` 302 } `json:"data"` 303 } `json:"ocs"` 304 }