namecrane: support server-sided dirmove/move, links

Adds support for server-sided DirMove and Move, required for certain applications using mounts, and full support for public link generation (with expiration)
This commit is contained in:
Tyler Stuyfzand 2025-04-06 20:24:17 -04:00
parent e9da24a89c
commit 314d78d8c3
2 changed files with 388 additions and 1 deletions

View File

@ -33,10 +33,14 @@ const (
apiUpload = "api/upload"
apiFiles = "api/v1/filestorage/files"
apiMoveFiles = "api/v1/filestorage/move-files"
apiEditFile = "api/v1/filestorage/{fileId}/edit"
apiGetFileLink = "api/v1/filestorage/{fileId}/getlink"
apiFolder = "api/v1/filestorage/folder"
apiFolders = "api/v1/filestorage/folders"
apiPutFolder = "api/v1/filestorage/folder-put"
apiDeleteFolder = "api/v1/filestorage/delete-folder"
apiPatchFolder = "api/v1/filestorage/folder-patch"
apiFileDownload = "api/v1/filestorage/%s/download"
)
@ -439,6 +443,10 @@ func (n *Namecrane) Find(ctx context.Context, file string) (*Folder, *File, erro
}
folder = &folders[0]
if name == "" {
return folder, nil, nil
}
} else {
var err error
@ -530,6 +538,175 @@ func (n *Namecrane) DeleteFolder(ctx context.Context, folder string) error {
return nil
}
type moveFilesRequest struct {
NewFolder string `json:"newFolder"`
FileIDs []string `json:"fileIDs"`
}
// MoveFiles moves files to the specified folder
func (n *Namecrane) MoveFiles(ctx context.Context, folder string, fileIDs ...string) error {
res, err := n.doRequest(ctx, http.MethodPost, apiMoveFiles, moveFilesRequest{
NewFolder: folder,
FileIDs: fileIDs,
})
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
}
var response FolderResponse
if err := res.Decode(&response); err != nil {
return err
}
if !response.Success {
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
}
return nil
}
type editFileRequest struct {
NewFilename string `json:"newFilename"`
}
// RenameFile will rename the specified file to the new name
func (n *Namecrane) RenameFile(ctx context.Context, fileID string, name string) error {
res, err := n.doRequest(ctx, http.MethodPost, apiEditFile, editFileRequest{
NewFilename: name,
}, WithURLParameter("fileId", fileID))
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
}
var response defaultResponse
if err := res.Decode(&response); err != nil {
return err
}
if !response.Success {
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
}
return nil
}
type EditFileParams struct {
Password string `json:"password"`
Published bool `json:"published"`
PublishedUntil time.Time `json:"publishedUntil"`
ShortLink string `json:"shortLink"`
PublicDownloadLink string `json:"publicDownloadLink"`
}
// EditFile updates a file on the backend
func (n *Namecrane) EditFile(ctx context.Context, fileID string, params EditFileParams) error {
res, err := n.doRequest(ctx, http.MethodPost, apiEditFile, params, WithURLParameter("fileId", fileID))
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
}
var response defaultResponse
if err := res.Decode(&response); err != nil {
return err
}
if !response.Success {
return fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
}
return nil
}
type linkResponse struct {
defaultResponse
PublicLink string `json:"publicLink"`
ShortLink string `json:"shortLink"`
IsPublic bool `json:"isPublic"`
}
// GetLink creates a short link and public link to a file
// This is combined with EditFile to make it public
func (n *Namecrane) GetLink(ctx context.Context, fileID string) (string, string, error) {
res, err := n.doRequest(ctx, http.MethodGet, apiGetFileLink, nil, WithURLParameter("fileId", fileID))
if err != nil {
return "", "", err
}
if res.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("%w: %d", ErrUnexpectedStatus, res.StatusCode)
}
var response linkResponse
if err := res.Decode(&response); err != nil {
return "", "", err
}
if !response.Success {
return "", "", fmt.Errorf("failed to create directory, status: %d, response: %s", res.StatusCode, response.Message)
}
return response.ShortLink, response.PublicLink, nil
}
type patchFolderRequest struct {
folderRequest
ParentFolder string `json:"parentFolder"`
Folder string `json:"folder"`
NewFolderName string `json:"newFolderName,omitempty"`
NewParentFolder string `json:"newParentFolder,omitempty"`
}
func (n *Namecrane) MoveFolder(ctx context.Context, folder, newParentFolder string) error {
_, subfolder := n.parsePath(folder)
res, err := n.doRequest(ctx, http.MethodPost, apiPatchFolder, patchFolderRequest{
//ParentFolder: parent,
Folder: folder,
NewParentFolder: newParentFolder,
NewFolderName: subfolder,
})
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d (%s)", ErrUnexpectedStatus, res.StatusCode, string(res.Data()))
}
var response defaultResponse
if err := res.Decode(&response); err != nil {
return err
}
if !response.Success {
return fmt.Errorf("failed to move directory, status: %d, response: %s", res.StatusCode, response.Message)
}
return nil
}
// apiUrl joins the base API URL with the path specified
func (n *Namecrane) apiUrl(subPath string) (string, error) {
u, err := url.Parse(n.apiURL)
@ -560,6 +737,23 @@ func WithHeader(key, value string) RequestOpt {
}
}
// WithURLParameter replaces a URL parameter encased in {} with the value
func WithURLParameter(key string, value any) RequestOpt {
return func(r *http.Request) {
var valStr string
switch v := value.(type) {
case string:
valStr = v
case int:
valStr = strconv.Itoa(v)
default:
valStr = fmt.Sprintf("%v", v)
}
r.URL.Path = strings.Replace(r.URL.Path, "{"+key+"}", valStr, -1)
}
}
func doHttpRequest(ctx context.Context, client *http.Client, method, u string, body any, opts ...RequestOpt) (*Response, error) {
var bodyReader io.Reader
var jsonBody bool
@ -581,6 +775,8 @@ func doHttpRequest(ctx context.Context, client *http.Client, method, u string, b
return nil, err
}
fs.Debugf(nil, "body: %s", string(b))
bodyReader = bytes.NewReader(b)
jsonBody = true

View File

@ -343,10 +343,16 @@ func (f *Fs) Sortable() bool {
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
fs.Debugf(f, "New object %s", remote)
remote = path.Join(f.root, remote)
folder, file, err := f.client.Find(ctx, remote)
if err != nil {
fs.Debugf(f, "Unable to find existing file, not necessarily a bad thing: %s", err.Error())
if errors.Is(err, ErrNoFile) {
return nil, fs.ErrorObjectNotFound
}
fs.Debugf(f, "Unable to find existing file at %s, not necessarily a bad thing: %s", remote, err.Error())
}
return &Object{
@ -357,6 +363,13 @@ func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
}, nil
}
func (f *Fs) newObject(remote string) *Object {
return &Object{
fs: f,
remote: remote,
}
}
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
remote := src.Remote()
@ -381,6 +394,181 @@ func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options .
}, nil
}
// DirMove moves src, srcRemote to this remote at dstRemote
// using server-side move operations.
//
// Will only be called if src.Fs().Name() == f.Name()
//
// If it isn't possible then return fs.ErrorCantDirMove
//
// If destination exists then return fs.ErrorDirExists
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
srcFs, ok := src.(*Fs)
if !ok {
fs.Debugf(srcFs, "Can't move directory - not same remote type")
return fs.ErrorCantDirMove
}
fs.Debugf(f, "Moving directory %s to %s", srcRemote, dstRemote)
srcRemote = path.Join(srcFs.root, srcRemote)
dstRemote = path.Join(f.root, dstRemote)
// Check source remote for the folder to move
folder, _, err := f.client.Find(ctx, srcRemote)
if err != nil || folder == nil {
return fs.ErrorDirNotFound
}
// Confirm that the parent folder exists in the destination path
parent, _, err := f.client.Find(ctx, dstRemote)
if errors.Is(err, ErrNoFile) {
// If the parent does not exist, create it (equivalent to MkdirAll)
parent, err = f.client.CreateFolder(ctx, dstRemote)
if err != nil {
return fs.ErrorDirNotFound
}
} else if err != nil {
return err
}
// Check dest path for existing folder (dstRemote + folder.Name)
existing, _, _ := f.client.Find(ctx, path.Join(dstRemote, folder.Name))
if existing != nil {
return fs.ErrorDirExists
}
// Use server side move
err = f.client.MoveFolder(ctx, folder.Path, parent.Path)
if err != nil {
// not quite clear, but probably trying to move directory across file system
// boundaries. Copying might still work.
fs.Debugf(src, "Can't move dir: %v: trying copy", err)
return fs.ErrorCantDirMove
}
return nil
}
// Move src to this remote using server-side move operations.
//
// This is stored with the remote path given.
//
// It returns the destination Object and a possible error.
//
// Will only be called if src.Fs().Name() == f.Name()
//
// If it isn't possible then return fs.ErrorCantMove
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
srcObj, ok := src.(*Object)
if !ok {
fs.Debugf(src, "Can't move - not same remote type")
return nil, fs.ErrorCantMove
}
remote = path.Join(f.root, remote)
// Temporary Object under construction
dstObj := f.newObject(remote)
// Check if the destination is a folder
_, err := dstObj.Stat(ctx)
if errors.Is(err, ErrNoFile) {
// OK
} else if err != nil {
return nil, err
}
if dstObj.folder != nil {
return nil, errors.New("can't move file onto non-file")
}
newFolder, _ := f.client.parsePath(remote)
baseFolder, _, err := f.client.Find(ctx, newFolder)
if err != nil && errors.Is(err, ErrNoFile) {
baseFolder, err = f.client.CreateFolder(ctx, newFolder)
if err != nil {
fs.Debugf(f, "Unable to create parent directory due to error %s", err.Error())
return nil, fs.ErrorDirNotFound
}
} else if err != nil {
fs.Debugf(f, "Unable to get parent directory due to error %s", err.Error())
return nil, err
}
err = f.client.MoveFiles(ctx, baseFolder.Path, srcObj.file.ID)
if err != nil {
return nil, err
}
_, err = dstObj.Stat(ctx)
if err != nil {
return nil, err
}
return dstObj, nil
}
// PublicLink generates a public link to the remote path (usually readable by anyone)
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
remote = path.Join(f.root, remote)
_, file, err := f.client.Find(ctx, remote)
if errors.Is(err, ErrNoFile) {
return "", fs.ErrorObjectNotFound
}
// Unlink just sets published to false
if unlink {
err = f.client.EditFile(ctx, file.ID, EditFileParams{
Published: false,
PublishedUntil: time.Time{},
})
return "", nil
}
// Generate the link
shortLink, publicLink, err := f.client.GetLink(ctx, file.ID)
if err != nil {
return "", err
}
publicLink = strings.TrimRight(f.apiURL, "/") + "/" + publicLink
params := EditFileParams{
ShortLink: shortLink,
PublicDownloadLink: publicLink,
Published: true,
}
if expire.IsSet() {
params.PublishedUntil = time.Now().Add(time.Duration(expire))
}
// Set the file to public
err = f.client.EditFile(ctx, file.ID, params)
if err != nil {
return "", err
}
return publicLink, nil
}
func (o *Object) ModTime(ctx context.Context) time.Time {
if o.file != nil {
return o.file.DateAdded
@ -508,6 +696,9 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op
// Remove deletes the file represented by the object from the remote.
func (o *Object) Remove(ctx context.Context) error {
if o.file == nil {
return fs.ErrorNotAFile
}
return o.fs.client.DeleteFiles(ctx, o.file.ID)
}