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  }