feat(oo): file deep links, login check, replace-in (fresh upload)
- 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)
This commit is contained in:
@@ -98,6 +98,48 @@ func (c *Client) authenticateOnce(ctx context.Context) error {
|
|||||||
return nil
|
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
|
// InvalidateToken clears the cached authentication token. The next request
|
||||||
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+3
-2
@@ -3,9 +3,10 @@
|
|||||||
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
||||||
//
|
//
|
||||||
// oo calendar list | events | add | delete
|
// 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 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 contacts list | get | delete | info-add | merge | dedupe-info | tags | tag-add | tag-create | tag-remove
|
||||||
// oo persons list | create | delete | dedupe
|
// oo persons list | create | delete | dedupe
|
||||||
// oo companies list | create | delete | dedupe | dedupe-persons
|
// oo companies list | create | delete | dedupe | dedupe-persons
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func projectFilesCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.AddCommand(prjFilesListCmd())
|
cmd.AddCommand(prjFilesListCmd())
|
||||||
cmd.AddCommand(prjFilesUploadCmd())
|
cmd.AddCommand(prjFilesUploadCmd())
|
||||||
|
cmd.AddCommand(prjFilesReplaceInCmd())
|
||||||
cmd.AddCommand(prjFilesUpdateCmd())
|
cmd.AddCommand(prjFilesUpdateCmd())
|
||||||
cmd.AddCommand(prjFilesDownloadCmd())
|
cmd.AddCommand(prjFilesDownloadCmd())
|
||||||
cmd.AddCommand(prjFilesRenameCmd())
|
cmd.AddCommand(prjFilesRenameCmd())
|
||||||
@@ -150,6 +151,44 @@ Pass --no-replace to fail when the name is taken; --allow-duplicate to always cr
|
|||||||
return cmd
|
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 {
|
func prjFilesUpdateCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "update FILE_ID LOCAL_PATH",
|
Use: "update FILE_ID LOCAL_PATH",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func init() {
|
|||||||
usersCmd.AddCommand(usersBlockCmd())
|
usersCmd.AddCommand(usersBlockCmd())
|
||||||
usersCmd.AddCommand(usersUnblockCmd())
|
usersCmd.AddCommand(usersUnblockCmd())
|
||||||
usersCmd.AddCommand(usersPasswordCmd())
|
usersCmd.AddCommand(usersPasswordCmd())
|
||||||
|
usersCmd.AddCommand(usersCheckCmd())
|
||||||
rootCmd.AddCommand(whoamiCmd())
|
rootCmd.AddCommand(whoamiCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +313,49 @@ func usersPasswordCmd() *cobra.Command {
|
|||||||
return cmd
|
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.
|
// readLine reads a single trimmed line from r.
|
||||||
func readLine(r *os.File) (string, error) {
|
func readLine(r *os.File) (string, error) {
|
||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
|
|||||||
@@ -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://<portal>/Products/Files/DocEditor.aspx?fileid=<id>
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user