From df38b750111e4a17826645c03204b5f942a3c924 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 19:48:19 +0100 Subject: [PATCH] feat(oo): file deep links, login check, replace-in (fresh upload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - link: print Products/Files/DocEditor.aspx?fileid=… deep links (title+url) - lib: FileEditorURL/FolderURL helpers (+tests) - users check: verify credentials (userName vs email) via authentication.json - projects files replace-in FOLDER_ID FILE...: hard delete same stem|ext in a folder + fresh upload → single clean version (no version history) --- auth.go | 42 ++++++++++++++++++++++++++++++++++++++ cmd/oo/link.go | 41 +++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 5 +++-- cmd/oo/projects_files.go | 39 +++++++++++++++++++++++++++++++++++ cmd/oo/users.go | 44 ++++++++++++++++++++++++++++++++++++++++ links.go | 37 +++++++++++++++++++++++++++++++++ links_test.go | 24 ++++++++++++++++++++++ 7 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 cmd/oo/link.go create mode 100644 links.go create mode 100644 links_test.go diff --git a/auth.go b/auth.go index ed897ad..1057743 100644 --- a/auth.go +++ b/auth.go @@ -98,6 +98,48 @@ func (c *Client) authenticateOnce(ctx context.Context) error { return nil } +// AuthenticateAs verifies a login/password pair against the portal WITHOUT +// mutating the client's cached token. It returns nil when the portal issues a +// token, and the portal error otherwise. +// +// OnlyOffice accepts either the userName or the account email as the login. On +// some portals the userName login fails while the email works — use this probe +// to tell them apart before sharing credentials. +func (c *Client) AuthenticateAs(ctx context.Context, login, password string) error { + body, err := json.Marshal(Credentials{User: login, Password: password}) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("auth request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 400 { + return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400)) + } + var env struct { + Response *Token `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return fmt.Errorf("auth decode: %w", err) + } + if env.Response == nil || env.Response.Value == "" { + return fmt.Errorf("auth: empty token in response") + } + return nil +} + // InvalidateToken clears the cached authentication token. The next request // (or call to Authenticate / AuthenticateContext) will re-authenticate. // diff --git a/cmd/oo/link.go b/cmd/oo/link.go new file mode 100644 index 0000000..05aa00e --- /dev/null +++ b/cmd/oo/link.go @@ -0,0 +1,41 @@ +package main + +import ( + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(linkCmd()) +} + +func linkCmd() *cobra.Command { + return &cobra.Command{ + Use: "link FILE_ID [FILE_ID...]", + Short: "Print OnlyOffice DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + for _, id := range args { + printObject(map[string]any{ + "fileid": id, + "title": fileTitle(cmd, c, id), + "url": c.FileEditorURL(id), + }) + } + return nil + }, + } +} + +// fileTitle best-effort resolves a file title; never fails the command. +func fileTitle(cmd *cobra.Command, c *onlyoffice.Client, id string) string { + f, err := c.GetFile(cmd.Context(), id) + if err != nil || f == nil || f.Title == nil { + return "" + } + return *f.Title +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index c6e39c1..bdfa068 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -3,9 +3,10 @@ // Command tree is subject-based (mirrors the library split and the `tea` CLI): // // oo calendar list | events | add | delete -// oo projects list | get | milestones | milestone-create | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) +// oo projects list | get | milestones | milestone-create | milestone-delete | create | update | delete | contacts (add|remove) | team (list|add|remove|set) | link-authors | link-git | files (list|upload|replace-in|update|download|rename|delete|dedupe|as-md|put-md|put-txt|put-xlsx) // oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach) -// oo users list | self | get | create | update | delete | block | unblock | password (alias: oo whoami) +// oo users list | self | get | create | update | delete | block | unblock | password | check (alias: oo whoami) +// oo link FILE_ID [FILE_ID...] DocEditor deep links (Products/Files/DocEditor.aspx?fileid=…) // oo contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove // oo persons list | create | delete | dedupe // oo companies list | create | delete | dedupe | dedupe-persons diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go index fe18d6c..c6dbc57 100644 --- a/cmd/oo/projects_files.go +++ b/cmd/oo/projects_files.go @@ -22,6 +22,7 @@ func projectFilesCmd() *cobra.Command { } cmd.AddCommand(prjFilesListCmd()) cmd.AddCommand(prjFilesUploadCmd()) + cmd.AddCommand(prjFilesReplaceInCmd()) cmd.AddCommand(prjFilesUpdateCmd()) cmd.AddCommand(prjFilesDownloadCmd()) cmd.AddCommand(prjFilesRenameCmd()) @@ -150,6 +151,44 @@ Pass --no-replace to fail when the name is taken; --allow-duplicate to always cr return cmd } +func prjFilesReplaceInCmd() *cobra.Command { + return &cobra.Command{ + Use: "replace-in FOLDER_ID LOCAL_PATH [LOCAL_PATH...]", + Short: "Replace same-named file(s) in a folder: hard delete + fresh upload (no version history)", + Long: `Deletes any file in FOLDER_ID with the same stem|ext (hard delete — the CLI +delete is permanent) and uploads the local file fresh. Unlike 'update' this +leaves a single clean version, which matters when the file id is shared. + +Note: file ids are server-assigned; a fresh upload gets a new id.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + folderID := args[0] + for _, p := range args[1:] { + stem := onlyoffice.UploadStemFromLocal(p) + ext := onlyoffice.UploadExtFromLocal(p) + deleted, derr := c.DeleteFilesByDedupKey(cmd.Context(), folderID, stem, ext) + if derr != nil { + return derr + } + ent, uerr := c.UploadToFolder(cmd.Context(), folderID, p) + if uerr != nil { + return uerr + } + obj := fileEntryToMap(ent) + if len(deleted) > 0 { + obj["replaced_file_ids"] = deleted + } + printObject(obj) + } + return nil + }, + } +} + func prjFilesUpdateCmd() *cobra.Command { return &cobra.Command{ Use: "update FILE_ID LOCAL_PATH", diff --git a/cmd/oo/users.go b/cmd/oo/users.go index d307eea..9fa1b69 100644 --- a/cmd/oo/users.go +++ b/cmd/oo/users.go @@ -26,6 +26,7 @@ func init() { usersCmd.AddCommand(usersBlockCmd()) usersCmd.AddCommand(usersUnblockCmd()) usersCmd.AddCommand(usersPasswordCmd()) + usersCmd.AddCommand(usersCheckCmd()) rootCmd.AddCommand(whoamiCmd()) } @@ -312,6 +313,49 @@ func usersPasswordCmd() *cobra.Command { return cmd } +func usersCheckCmd() *cobra.Command { + var login, password string + cmd := &cobra.Command{ + Use: "check", + Short: "Check that a login can authenticate (userName or email)", + Long: `Probes POST /api/2.0/authentication.json with the given credentials and +discards the token. On this portal the account email is the reliable login +identifier (userName login may fail); use this before sharing credentials.`, + RunE: func(cmd *cobra.Command, args []string) error { + if login == "" { + return fmt.Errorf("--login is required (userName or email)") + } + if password == "" { + if b, err := readLine(os.Stdin); err == nil { + password = b + } + } + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.AuthenticateAs(cmd.Context(), login, password); err != nil { + printObject(map[string]any{"login": login, "ok": false, "error": trimAuthErr(err)}) + return fmt.Errorf("login failed for %s", login) + } + printObject(map[string]any{"login": login, "ok": true}) + return nil + }, + } + cmd.Flags().StringVar(&login, "login", "", "userName or email") + cmd.Flags().StringVar(&password, "password", "", "password (omit to read one line from stdin)") + return cmd +} + +// trimAuthErr keeps the error short for table output. +func trimAuthErr(err error) string { + s := err.Error() + if len(s) > 160 { + s = s[:160] + "…" + } + return s +} + // readLine reads a single trimmed line from r. func readLine(r *os.File) (string, error) { sc := bufio.NewScanner(r) diff --git a/links.go b/links.go new file mode 100644 index 0000000..eb22fab --- /dev/null +++ b/links.go @@ -0,0 +1,37 @@ +package onlyoffice + +// Deep links to OnlyOffice portal objects. + +import ( + "fmt" + "net/url" + "strings" +) + +// FileEditorURL builds the OnlyOffice DocEditor deep link for a portal file id: +// +// https:///Products/Files/DocEditor.aspx?fileid= +// +// portalBase may include a trailing slash; fileID is trimmed and URL-escaped. +func FileEditorURL(portalBase, fileID string) string { + base := strings.TrimRight(strings.TrimSpace(portalBase), "/") + return fmt.Sprintf("%s/Products/Files/DocEditor.aspx?fileid=%s", + base, url.QueryEscape(strings.TrimSpace(fileID))) +} + +// FileEditorURL is the client-bound convenience wrapper (uses the portal URL +// the client was built with). +func (c *Client) FileEditorURL(fileID string) string { + return FileEditorURL(c.baseURL(), fileID) +} + +// FolderURL builds a Documents-folder deep link for a folder id. +func FolderURL(portalBase, folderID string) string { + base := strings.TrimRight(strings.TrimSpace(portalBase), "/") + return fmt.Sprintf("%s/Products/Files/Default.aspx#folder=%s", base, url.QueryEscape(strings.TrimSpace(folderID))) +} + +// FolderURL is the client-bound convenience wrapper. +func (c *Client) FolderURL(folderID string) string { + return FolderURL(c.baseURL(), folderID) +} diff --git a/links_test.go b/links_test.go new file mode 100644 index 0000000..e443e19 --- /dev/null +++ b/links_test.go @@ -0,0 +1,24 @@ +package onlyoffice + +import "testing" + +func TestFileEditorURL(t *testing.T) { + cases := []struct{ base, id, want string }{ + {"https://office.example.com", "3651", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3651"}, + {"https://office.example.com/", " 3687 ", "https://office.example.com/Products/Files/DocEditor.aspx?fileid=3687"}, + {"http://localhost:8087", "a/b", "http://localhost:8087/Products/Files/DocEditor.aspx?fileid=a%2Fb"}, + } + for _, c := range cases { + if got := FileEditorURL(c.base, c.id); got != c.want { + t.Fatalf("FileEditorURL(%q,%q) = %q, want %q", c.base, c.id, got, c.want) + } + } +} + +func TestFolderURL(t *testing.T) { + got := FolderURL("https://office.example.com/", "495") + want := "https://office.example.com/Products/Files/Default.aspx#folder=495" + if got != want { + t.Fatalf("FolderURL = %q, want %q", got, want) + } +}