github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/cmd/files.go (about)

     1  package cmd
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"flag"
     7  	"fmt"
     8  	"io"
     9  	"mime"
    10  	"os"
    11  	"path"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strconv"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/cozy/cozy-stack/client"
    19  	"github.com/cozy/cozy-stack/pkg/consts"
    20  	humanize "github.com/dustin/go-humanize"
    21  	"github.com/spf13/cobra"
    22  )
    23  
    24  var errFilesExec = errors.New("Bad usage of files exec")
    25  
    26  const filesExecUsage = `Available commands:
    27  
    28      mkdir <name>               Creates a directory with specified name
    29      ls [-l] [-a] [-h] <name>   Prints the children of the specified directory
    30      tree [-l] <name>           Prints the tree structure of the specified directory
    31      attrs <name>               Prints the attributes of the specified file or directory
    32      cat <name>                 Echo the file content in stdout
    33      mv <from> <to>             Rename a file or directory
    34      rm [-f] [-r] <name>        Move the file to trash, or delete it permanently with -f flag
    35      restore <name>             Restore a file or directory from trash
    36  
    37  	Don't forget to put quotes around the command!
    38  `
    39  
    40  var flagImportFrom string
    41  var flagImportTo string
    42  var flagImportDryRun bool
    43  var flagImportMatch string
    44  var flagIncludeTrash bool
    45  
    46  // filesCmdGroup represents the instances command
    47  var filesCmdGroup = &cobra.Command{
    48  	Use:   "files <command>",
    49  	Short: "Interact with the cozy filesystem",
    50  	Long: `
    51  cozy-stack files allows to interact with the cozy filesystem.
    52  
    53  It provides command to create, move copy or delete files and
    54  directories inside your cozy instance, using the command line
    55  interface. It also provide an import command to import from your
    56  current filesystem into cozy.
    57  `,
    58  	RunE: func(cmd *cobra.Command, args []string) error {
    59  		return cmd.Usage()
    60  	},
    61  }
    62  
    63  var execFilesCmd = &cobra.Command{
    64  	Use:   "exec [--domain domain] <command>",
    65  	Short: "Execute the given command on the specified domain and leave",
    66  	Long:  "Execute a command on the VFS of the specified domain.\n" + filesExecUsage,
    67  	RunE: func(cmd *cobra.Command, args []string) error {
    68  		if len(args) != 1 {
    69  			return cmd.Usage()
    70  		}
    71  		if flagDomain == "" {
    72  			errPrintfln("%s", errMissingDomain)
    73  			return cmd.Usage()
    74  		}
    75  		c := newClient(flagDomain, consts.Files)
    76  		command := args[0]
    77  		err := execCommand(c, command, os.Stdout)
    78  		if errors.Is(err, errFilesExec) {
    79  			return cmd.Usage()
    80  		}
    81  		return err
    82  	},
    83  }
    84  
    85  var importFilesCmd = &cobra.Command{
    86  	Use:   "import [--domain domain] [--match pattern] --from <name> --to <name>",
    87  	Short: "Import the specified file or directory into cozy",
    88  	RunE: func(cmd *cobra.Command, args []string) error {
    89  		if flagDomain == "" {
    90  			errPrintfln("%s", errMissingDomain)
    91  			return cmd.Usage()
    92  		}
    93  		if flagImportFrom == "" || flagImportTo == "" {
    94  			return cmd.Usage()
    95  		}
    96  
    97  		var match *regexp.Regexp
    98  		if flagImportMatch != "" {
    99  			var err error
   100  			match, err = regexp.Compile(flagImportMatch)
   101  			if err != nil {
   102  				return err
   103  			}
   104  		}
   105  
   106  		c := newClient(flagDomain, consts.Files)
   107  		return importFiles(c, flagImportFrom, flagImportTo, match)
   108  	},
   109  }
   110  
   111  var usageFilesCmd = &cobra.Command{
   112  	Use:   "usage [--domain domain] [--trash]",
   113  	Short: "Show the usage and quota for the files of this instance",
   114  	RunE: func(cmd *cobra.Command, args []string) error {
   115  		if flagDomain == "" {
   116  			errPrintfln("%s", errMissingDomain)
   117  			return cmd.Usage()
   118  		}
   119  		ac := newAdminClient()
   120  		info, err := ac.DiskUsage(flagDomain, flagIncludeTrash)
   121  		if err != nil {
   122  			return err
   123  		}
   124  		fmt.Fprintf(os.Stdout, "Usage: %v\n", info["used"])
   125  
   126  		if files, ok := info["files"]; ok {
   127  			fmt.Fprintf(os.Stdout, "  Including latest version of files: %v\n", files)
   128  		}
   129  		if versions, ok := info["versions"]; ok {
   130  			fmt.Fprintf(os.Stdout, "  Including older versions of files: %v\n", versions)
   131  		}
   132  
   133  		if flagIncludeTrash {
   134  			if trashed, ok := info["trashed"]; ok {
   135  				fmt.Fprintf(os.Stdout, "  Including trashed files: %v\n", trashed)
   136  			}
   137  		}
   138  
   139  		if quota, ok := info["quota"]; ok {
   140  			fmt.Fprintf(os.Stdout, "Quota: %v\n", quota)
   141  		}
   142  		if count, ok := info["doc_count"]; ok {
   143  			fmt.Fprintf(os.Stdout, "Documents count: %v\n", count)
   144  		}
   145  		if count, ok := info["versions_count"]; ok {
   146  			fmt.Fprintf(os.Stdout, "Versions Documents count: %v\n", count)
   147  		}
   148  		return nil
   149  	},
   150  }
   151  
   152  func execCommand(c *client.Client, command string, w io.Writer) error {
   153  	args := splitArgs(command)
   154  	if len(args) == 0 {
   155  		return errFilesExec
   156  	}
   157  
   158  	cmdname := args[0]
   159  
   160  	fset := flag.NewFlagSet("", flag.ContinueOnError)
   161  
   162  	var flagMkdirP bool
   163  	var flagLsVerbose bool
   164  	var flagLsHuman bool
   165  	var flagLsAll bool
   166  	var flagRmForce bool
   167  	var flagRmRecur bool
   168  
   169  	switch cmdname {
   170  	case "mkdir":
   171  		fset.BoolVar(&flagMkdirP, "p", false, "Create intermediary directories")
   172  	case "ls":
   173  		fset.BoolVar(&flagLsVerbose, "l", false, "List in with additional attributes")
   174  		fset.BoolVar(&flagLsHuman, "h", false, "Print size in human readable format")
   175  		fset.BoolVar(&flagLsAll, "a", false, "Print hidden directories")
   176  	case "tree":
   177  		fset.BoolVar(&flagLsVerbose, "l", false, "List in with additional attributes")
   178  	case "rm":
   179  		fset.BoolVar(&flagRmForce, "f", false, "Delete file or directory permanently")
   180  		fset.BoolVar(&flagRmRecur, "r", false, "Delete directory and all its contents")
   181  	}
   182  
   183  	if err := fset.Parse(args[1:]); err != nil {
   184  		return err
   185  	}
   186  
   187  	args = fset.Args()
   188  	if len(args) == 0 {
   189  		return errFilesExec
   190  	}
   191  
   192  	switch cmdname {
   193  	case "mkdir":
   194  		return mkdirCmd(c, args[0], flagMkdirP)
   195  	case "ls":
   196  		return lsCmd(c, args[0], w, flagLsVerbose, flagLsHuman, flagLsAll)
   197  	case "tree":
   198  		return treeCmd(c, args[0], w, flagLsVerbose)
   199  	case "attrs":
   200  		return attrsCmd(c, args[0], w)
   201  	case "cat":
   202  		return catCmd(c, args[0], w)
   203  	case "mv":
   204  		if len(args) < 2 {
   205  			return errFilesExec
   206  		}
   207  		return mvCmd(c, args[0], args[1])
   208  	case "rm":
   209  		return rmCmd(c, args[0], flagRmForce, flagRmRecur)
   210  	case "restore":
   211  		return restoreCmd(c, args[0])
   212  	}
   213  
   214  	return errFilesExec
   215  }
   216  
   217  func mkdirCmd(c *client.Client, name string, mkdirP bool) error {
   218  	var err error
   219  	if mkdirP {
   220  		_, err = c.Mkdirall(name)
   221  	} else {
   222  		_, err = c.Mkdir(name)
   223  	}
   224  	return err
   225  }
   226  
   227  func lsCmd(c *client.Client, root string, w io.Writer, verbose, human, all bool) error {
   228  	type filePrint struct {
   229  		id    string
   230  		typ   string
   231  		name  string
   232  		size  string
   233  		mdate string
   234  		exec  string
   235  	}
   236  
   237  	now := time.Now()
   238  
   239  	var prints []*filePrint
   240  	var maxnamelen int
   241  	var maxsizelen int
   242  
   243  	root = path.Clean(root)
   244  
   245  	err := c.WalkByPath(root, func(n string, doc *client.DirOrFile, err error) error {
   246  		if err != nil {
   247  			return err
   248  		}
   249  		if n == root && doc.Attrs.Type == consts.DirType {
   250  			return nil
   251  		}
   252  
   253  		attrs := doc.Attrs
   254  		var typ, size, mdate, exec string
   255  		name := attrs.Name
   256  		id := doc.ID
   257  
   258  		if now.Year() == attrs.UpdatedAt.Year() {
   259  			mdate = attrs.UpdatedAt.Format("Jan 02 15:04")
   260  		} else {
   261  			mdate = attrs.UpdatedAt.Format("Jan 02 2015")
   262  		}
   263  
   264  		if attrs.Type == consts.DirType {
   265  			typ = "d"
   266  			exec = "x"
   267  		} else {
   268  			typ = "-"
   269  			if attrs.Executable {
   270  				exec = "x"
   271  			} else {
   272  				exec = "-"
   273  			}
   274  			if human {
   275  				size = humanize.Bytes(uint64(attrs.Size))
   276  			} else {
   277  				size = humanize.Comma(attrs.Size)
   278  			}
   279  		}
   280  
   281  		if len(name) > maxnamelen {
   282  			maxnamelen = len(name)
   283  		}
   284  
   285  		if len(size) > maxsizelen {
   286  			maxsizelen = len(size)
   287  		}
   288  
   289  		if all || len(name) == 0 || name[0] != '.' {
   290  			prints = append(prints, &filePrint{
   291  				id:    id,
   292  				typ:   typ,
   293  				name:  name,
   294  				size:  size,
   295  				mdate: mdate,
   296  				exec:  exec,
   297  			})
   298  		}
   299  		if doc.Attrs.Type == client.DirType {
   300  			return filepath.SkipDir
   301  		}
   302  		return nil
   303  	})
   304  
   305  	if err != nil {
   306  		return err
   307  	}
   308  
   309  	if !verbose {
   310  		for _, fp := range prints {
   311  			_, err = fmt.Fprintln(w, fp.name)
   312  			if err != nil {
   313  				return err
   314  			}
   315  		}
   316  		return nil
   317  	}
   318  
   319  	smaxsizelen := strconv.Itoa(maxsizelen)
   320  	smaxnamelen := strconv.Itoa(maxnamelen)
   321  
   322  	for _, fp := range prints {
   323  		_, err = fmt.Fprintf(w, "%s %s%s  %"+smaxsizelen+"s %s %-"+smaxnamelen+"s\n",
   324  			fp.id, fp.typ, fp.exec, fp.size, fp.mdate, fp.name)
   325  		if err != nil {
   326  			return err
   327  		}
   328  	}
   329  
   330  	return nil
   331  }
   332  
   333  func treeCmd(c *client.Client, root string, w io.Writer, verbose bool) error {
   334  	root = path.Clean(root)
   335  
   336  	return c.WalkByPath(root, func(name string, doc *client.DirOrFile, err error) error {
   337  		if err != nil {
   338  			return err
   339  		}
   340  		if verbose {
   341  			fmt.Fprintf(os.Stdout, "%s ", doc.ID)
   342  		}
   343  
   344  		attrs := doc.Attrs
   345  		if name == root {
   346  			_, err = fmt.Fprintln(w, name)
   347  			return err
   348  		}
   349  
   350  		level := strings.Count(strings.TrimPrefix(name, root)[1:], "/") + 1
   351  		for i := 0; i < level; i++ {
   352  			if i == level-1 {
   353  				_, err = fmt.Fprintf(w, "└── ")
   354  			} else {
   355  				_, err = fmt.Fprintf(w, "|  ")
   356  			}
   357  			if err != nil {
   358  				return err
   359  			}
   360  		}
   361  		_, err = fmt.Fprintln(w, attrs.Name)
   362  		return err
   363  	})
   364  }
   365  
   366  func attrsCmd(c *client.Client, name string, w io.Writer) error {
   367  	doc, err := c.GetDirOrFileByPath(name)
   368  	if err != nil {
   369  		return err
   370  	}
   371  	enc := json.NewEncoder(w)
   372  	enc.SetIndent("", "\t")
   373  	return enc.Encode(doc)
   374  }
   375  
   376  func catCmd(c *client.Client, name string, w io.Writer) error {
   377  	r, err := c.DownloadByPath(name)
   378  	if err != nil {
   379  		return err
   380  	}
   381  
   382  	defer r.Close()
   383  	_, err = io.Copy(w, r)
   384  
   385  	return err
   386  }
   387  
   388  func mvCmd(c *client.Client, from, to string) error {
   389  	return c.Move(from, to)
   390  }
   391  
   392  func rmCmd(c *client.Client, name string, force, recur bool) error {
   393  	if force {
   394  		return c.PermanentDeleteByPath(name)
   395  	}
   396  	return c.TrashByPath(name)
   397  }
   398  
   399  func restoreCmd(c *client.Client, name string) error {
   400  	return c.RestoreByPath(name)
   401  }
   402  
   403  type importer struct {
   404  	c     *client.Client
   405  	paths map[string]string
   406  }
   407  
   408  func (i *importer) mkdir(name string) (string, error) {
   409  	doc, err := i.c.Mkdirall(name)
   410  	if err != nil {
   411  		return "", err
   412  	}
   413  	i.paths[name] = doc.ID
   414  	return doc.ID, nil
   415  }
   416  
   417  func (i *importer) upload(localname, distname string) error {
   418  	var err error
   419  
   420  	dirname := path.Dir(distname)
   421  	dirID, ok := i.paths[dirname]
   422  	if !ok && dirname != string(os.PathSeparator) {
   423  		dirID, err = i.mkdir(dirname)
   424  		if err != nil {
   425  			return err
   426  		}
   427  	}
   428  
   429  	infos, err := os.Stat(localname)
   430  	if err != nil {
   431  		return err
   432  	}
   433  
   434  	r, err := os.Open(localname)
   435  	if err != nil {
   436  		return err
   437  	}
   438  	defer r.Close()
   439  
   440  	_, err = i.c.Upload(&client.Upload{
   441  		Name:          path.Base(distname),
   442  		DirID:         dirID,
   443  		Contents:      r,
   444  		ContentLength: infos.Size(),
   445  		ContentType:   mime.TypeByExtension(localname),
   446  	})
   447  	return err
   448  }
   449  
   450  func importFiles(c *client.Client, from, to string, match *regexp.Regexp) error {
   451  	from = path.Clean(from)
   452  	to = path.Clean(to)
   453  
   454  	i := &importer{
   455  		c:     c,
   456  		paths: make(map[string]string),
   457  	}
   458  
   459  	fromInfos, err := os.Stat(from)
   460  	if err != nil {
   461  		return err
   462  	}
   463  	if !fromInfos.IsDir() {
   464  		fmt.Fprintf(os.Stdout, "Importing file %s to cozy://%s\n", from, to)
   465  		return i.upload(from, to)
   466  	}
   467  
   468  	fmt.Fprintf(os.Stdout, "Importing from %s to cozy://%s\n", from, to)
   469  
   470  	return filepath.Walk(from, func(localname string, f os.FileInfo, err error) error {
   471  		if err != nil {
   472  			return err
   473  		}
   474  
   475  		if match != nil && !match.MatchString(localname) {
   476  			return nil
   477  		}
   478  
   479  		if localname == from {
   480  			if f.IsDir() {
   481  				return nil
   482  			}
   483  			return fmt.Errorf("Not a directory: %s", localname)
   484  		}
   485  
   486  		distname := path.Join(to, strings.TrimPrefix(localname, from))
   487  		if f.IsDir() {
   488  			fmt.Fprintf(os.Stdout, "create dir %s\n", distname)
   489  			if !flagImportDryRun {
   490  				if _, err = i.mkdir(distname); err != nil {
   491  					return err
   492  				}
   493  			}
   494  		} else {
   495  			fmt.Fprintf(os.Stdout, "copying file %s to %s\n", localname, distname)
   496  			if !flagImportDryRun {
   497  				return i.upload(localname, distname)
   498  			}
   499  		}
   500  
   501  		return nil
   502  	})
   503  }
   504  
   505  func splitArgs(command string) []string {
   506  	args := regexp.MustCompile("'.+'|\".+\"|\\S+").FindAllString(command, -1)
   507  	for i, a := range args {
   508  		l := len(a)
   509  		switch {
   510  		case a[0] == '\'' && a[l-1] == '\'':
   511  			args[i] = strings.Trim(a, "'")
   512  		case a[0] == '"' && a[l-1] == '"':
   513  			args[i] = strings.Trim(a, "\"")
   514  		}
   515  	}
   516  	return args
   517  }
   518  
   519  func init() {
   520  	filesCmdGroup.PersistentFlags().StringVar(&flagDomain, "domain", cozyDomain(), "specify the domain name of the instance")
   521  
   522  	importFilesCmd.Flags().StringVar(&flagImportFrom, "from", "", "directory to import from in cozy")
   523  	_ = importFilesCmd.MarkFlagRequired("from")
   524  	importFilesCmd.Flags().StringVar(&flagImportTo, "to", "/", "directory to import to in cozy")
   525  	_ = importFilesCmd.MarkFlagRequired("to")
   526  	importFilesCmd.Flags().BoolVar(&flagImportDryRun, "dry-run", false, "do not actually import the files")
   527  	importFilesCmd.Flags().StringVar(&flagImportMatch, "match", "", "pattern that the imported files must match")
   528  
   529  	usageFilesCmd.Flags().BoolVar(&flagIncludeTrash, "trash", false, "Include trashed files total size")
   530  
   531  	filesCmdGroup.AddCommand(execFilesCmd)
   532  	filesCmdGroup.AddCommand(importFilesCmd)
   533  	filesCmdGroup.AddCommand(usageFilesCmd)
   534  
   535  	RootCmd.AddCommand(filesCmdGroup)
   536  }