Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac777c031 | ||
|
|
c576bfe2ee | ||
|
|
7f56332285 | ||
|
|
a5807b9c31 | ||
|
|
d650a16a36 | ||
|
|
3da11586f9 | ||
|
|
e9c969a89c | ||
|
|
4d91726179 | ||
|
|
4d8af7a2fe | ||
|
|
34745e349e | ||
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d | ||
|
|
35f0cb8d20 |
@@ -25,3 +25,12 @@ ONLYOFFICE_PROJECT_ID=33
|
||||
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
|
||||
# ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
# ONLYOFFICE_DOCS_SECRET=
|
||||
|
||||
# MinIO download fallback for the portal's stale AWS S3 consumer (older
|
||||
# Documents folders). When the portal redirects to amazonaws.com with access
|
||||
# key "minio" (403 InvalidAccessKeyId), files are fetched from the local MinIO
|
||||
# store instead. Without a key/secret the fallback is disabled.
|
||||
# MINIO_ENDPOINT=http://192.168.188.10:9000
|
||||
# MINIO_BUCKET=office
|
||||
# MINIO_ACCESS_KEY=
|
||||
# MINIO_SECRET_KEY=
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
github: eSlider
|
||||
ko_fi: eslider
|
||||
liberapay: eslider
|
||||
patreon: eslider
|
||||
custom:
|
||||
- https://polar.sh/eslider
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.16.0"
|
||||
".": "0.17.0"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
||||
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
||||
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
||||
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`).
|
||||
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). All upload paths default to **upsert** by `stem|ext` (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
|
||||
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
||||
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
||||
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||
|
||||
@@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
|
||||
|
||||
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
||||
|
||||
### Features
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(davCmd())
|
||||
}
|
||||
|
||||
// davCmd exposes the Documents module through the same Dav calls that back
|
||||
// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile).
|
||||
// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy
|
||||
// fileops/move call without those params silently ignores (200 without move).
|
||||
func davCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "dav",
|
||||
Short: "Documents module by folder/file id (oo-webdav proven path)",
|
||||
}
|
||||
cmd.AddCommand(davLsCmd())
|
||||
cmd.AddCommand(davMoveCmd())
|
||||
cmd.AddCommand(davCopyCmd())
|
||||
cmd.AddCommand(davMkdirCmd())
|
||||
cmd.AddCommand(davRenameFileCmd())
|
||||
cmd.AddCommand(davRenameFolderCmd())
|
||||
cmd.AddCommand(davDownloadCmd())
|
||||
cmd.AddCommand(davFileOpsCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davLsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ls FOLDER_ID",
|
||||
Short: "List a Documents folder (@root for virtual sections)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if args[0] == "@root" {
|
||||
sections, err := c.ListDavSections(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(sections))
|
||||
for _, s := range sections {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"filesCount": s.FilesCount,
|
||||
"foldersCount": s.FoldersCount,
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows)
|
||||
return nil
|
||||
}
|
||||
l, err := c.ListDavFolder(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outputFormat == "json" {
|
||||
folders := make([]map[string]any, 0, len(l.Folders))
|
||||
for _, f := range l.Folders {
|
||||
folders = append(folders, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"filesCount": f.FilesCount,
|
||||
"foldersCount": f.FoldersCount,
|
||||
})
|
||||
}
|
||||
files := make([]map[string]any, 0, len(l.Files))
|
||||
for _, f := range l.Files {
|
||||
files = append(files, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"size": f.Size,
|
||||
"updated": f.Updated,
|
||||
})
|
||||
}
|
||||
printObject(map[string]any{"folders": folders, "files": files})
|
||||
return nil
|
||||
}
|
||||
if len(l.Folders) > 0 {
|
||||
frows := make([]map[string]any, 0, len(l.Folders))
|
||||
for _, f := range l.Folders {
|
||||
frows = append(frows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"filesCount": f.FilesCount,
|
||||
"foldersCount": f.FoldersCount,
|
||||
})
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Println("folders:")
|
||||
}
|
||||
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(l.Files))
|
||||
for _, f := range l.Files {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": f.ID,
|
||||
"title": f.Title,
|
||||
"size": f.Size,
|
||||
"updated": f.Updated,
|
||||
})
|
||||
}
|
||||
if outputFormat == "table" {
|
||||
fmt.Println("files:")
|
||||
}
|
||||
printTable([]string{"id", "title", "size", "updated"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davMoveCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "move DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||
Short: "Move file(s) into a Documents folder (resolveType=Skip, holdResult)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to move along with the files")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davCopyCmd() *cobra.Command {
|
||||
var folderIDs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "copy DEST_FOLDER_ID FILE_ID [FILE_ID...]",
|
||||
Short: "Copy file(s) into a Documents folder (conflictResolveType=Skip)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to copy along with the files")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davMkdirCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "mkdir PARENT_FOLDER_ID TITLE",
|
||||
Short: "Create a subfolder in Documents",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": f.ID, "title": f.Title, "parent": args[0]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davRenameFileCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename-file FILE_ID NEW_TITLE",
|
||||
Short: "Rename a Documents file (include extension in NEW_TITLE)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davRenameFolderCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rename-folder FOLDER_ID NEW_TITLE",
|
||||
Short: "Rename a Documents folder",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"id": args[0], "title": args[1]})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func davDownloadCmd() *cobra.Command {
|
||||
var to string
|
||||
cmd := &cobra.Command{
|
||||
Use: "download FILE_ID",
|
||||
Short: "Download Documents file bytes (default path: ./<title>)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
f, err := c.GetFile(ctx, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := to
|
||||
if path == "" {
|
||||
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
|
||||
}
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
n, err := c.DownloadDavFile(ctx, args[0], out)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{"path": path, "bytes": n})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func davFileOpsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "fileops",
|
||||
Short: "List active file operations (move/copy status polling)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ops, err := c.ListFileOps(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(ops))
|
||||
for _, op := range ops {
|
||||
rows = append(rows, map[string]any{
|
||||
"id": fmt.Sprint(op["id"]),
|
||||
"operation": fmt.Sprint(op["operation"]),
|
||||
"progress": fmt.Sprint(op["progress"]),
|
||||
"finished": fmt.Sprint(op["finished"]),
|
||||
"error": fmt.Sprint(op["error"]),
|
||||
})
|
||||
}
|
||||
printTable([]string{"id", "operation", "progress", "finished", "error"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
+32
-58
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -423,44 +424,28 @@ func docsPutMDCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
var ent *onlyoffice.FileEntry
|
||||
var deleted []int
|
||||
if folderID != "" {
|
||||
if replace {
|
||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
|
||||
} else {
|
||||
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
|
||||
}
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"project_id": pid,
|
||||
"folder_id": folderID,
|
||||
"md": mdPath,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
}
|
||||
if folderID != "" {
|
||||
obj["folder_id"] = folderID
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
}
|
||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{
|
||||
"project_id": pid,
|
||||
"md": mdPath,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -503,44 +488,28 @@ func docsPutTxtCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
var ent *onlyoffice.FileEntry
|
||||
var deleted []int
|
||||
if folderID != "" {
|
||||
if replace {
|
||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
|
||||
} else {
|
||||
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
|
||||
}
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj := map[string]any{
|
||||
"project_id": pid,
|
||||
"txt": txtPath,
|
||||
"folder_id": folderID,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
}
|
||||
if folderID != "" {
|
||||
obj["folder_id"] = folderID
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
return nil
|
||||
}
|
||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(map[string]any{
|
||||
"project_id": pid,
|
||||
"txt": txtPath,
|
||||
"uploaded": fileEntryToMap(ent),
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -618,21 +587,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
var ent *onlyoffice.FileEntry
|
||||
var deleted []int
|
||||
if folderID != "" {
|
||||
if replace {
|
||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, xlsxPath)
|
||||
} else {
|
||||
ent, err = c.UploadToFolder(ctx, folderID, xlsxPath)
|
||||
}
|
||||
} else {
|
||||
if replace {
|
||||
ent, deleted, err = c.UploadProjectFileReplacing(ctx, pid, xlsxPath)
|
||||
} else {
|
||||
ent, err = c.UploadProjectFile(ctx, pid, xlsxPath)
|
||||
}
|
||||
}
|
||||
ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -655,6 +610,25 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
|
||||
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
|
||||
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
|
||||
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files before upload")
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// uploadProjectDoc upserts (--replace, default) or no-clobbers into project/folder Documents.
|
||||
func uploadProjectDoc(ctx context.Context, c *onlyoffice.Client, pid, localPath, folderID string, replace bool) (*onlyoffice.FileEntry, []int, error) {
|
||||
if folderID != "" {
|
||||
if replace {
|
||||
return c.UploadToFolderReplacing(ctx, folderID, localPath)
|
||||
}
|
||||
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ent, err := c.UploadToFolder(ctx, folderID, localPath)
|
||||
return ent, nil, err
|
||||
}
|
||||
if replace {
|
||||
return c.UploadProjectFileReplacing(ctx, pid, localPath)
|
||||
}
|
||||
ent, err := c.UploadProjectFileNoClobber(ctx, pid, localPath)
|
||||
return ent, nil, err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
|
||||
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||
// oo docs tools | convert | ocr | as-md | put-md
|
||||
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops
|
||||
//
|
||||
// CRM association rules: docs/crm-associations.md
|
||||
//
|
||||
|
||||
@@ -107,9 +107,12 @@ func prjFilesListCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func prjFilesUploadCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var replace, allowDuplicate bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
||||
Short: "Upload file(s) into the project's Documents folder",
|
||||
Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
|
||||
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
||||
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newOO(cmd)
|
||||
@@ -118,15 +121,31 @@ func prjFilesUploadCmd() *cobra.Command {
|
||||
}
|
||||
pid := args[0]
|
||||
for _, p := range args[1:] {
|
||||
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
|
||||
var entry *onlyoffice.FileEntry
|
||||
var deleted []int
|
||||
switch {
|
||||
case allowDuplicate:
|
||||
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
|
||||
case replace:
|
||||
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
|
||||
default:
|
||||
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printObject(fileEntryToMap(entry))
|
||||
obj := fileEntryToMap(entry)
|
||||
if len(deleted) > 0 {
|
||||
obj["replaced_file_ids"] = deleted
|
||||
}
|
||||
printObject(obj)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
|
||||
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func prjFilesDownloadCmd() *cobra.Command {
|
||||
|
||||
@@ -15,10 +15,15 @@ import (
|
||||
)
|
||||
|
||||
// ListContacts returns a page of CRM contacts and the total count.
|
||||
// sortBy=id is always set: OnlyOffice filter.json without an explicit sort
|
||||
// order is non-deterministic on large contact sets, so a paged walk
|
||||
// (ListAllContacts, ListContactsByTag, FindCompany, FindPerson) can skip or
|
||||
// duplicate contacts across page boundaries.
|
||||
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
q.Set("sortBy", "id")
|
||||
if search != "" {
|
||||
q.Set("filterValue", search)
|
||||
}
|
||||
@@ -243,6 +248,24 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// UpdateContactName renames a CRM company contact displayName.
|
||||
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
|
||||
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
|
||||
body := map[string]any{
|
||||
"displayName": newName,
|
||||
"companyName": newName,
|
||||
"isCompany": true,
|
||||
}
|
||||
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
|
||||
out = fresh
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
||||
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
||||
@@ -288,6 +311,7 @@ func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, s
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
q.Set("tags", tagName)
|
||||
q.Set("sortBy", "id")
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -717,6 +741,11 @@ func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any,
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
|
||||
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
|
||||
}
|
||||
|
||||
// ListTaskCategories returns CRM task categories.
|
||||
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
@@ -321,11 +320,21 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
||||
"folderIds": []int{},
|
||||
"fileIds": fileIDs,
|
||||
"destFolderId": destFolderID,
|
||||
"resolveType": "Skip",
|
||||
"holdResult": true,
|
||||
}
|
||||
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||
if err != nil {
|
||||
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw, merr := json.Marshal(out); merr == nil {
|
||||
if ferr := fileopsError(raw); ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -346,6 +355,24 @@ func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string)
|
||||
return decodeResponseFileEntry(raw)
|
||||
}
|
||||
|
||||
// UpdateFile uploads a new version of an existing file (same id, name and
|
||||
// folder). It does not delete and does not create a second file.
|
||||
func (c *Client) UpdateFile(ctx context.Context, fileID, localPath string) (*FileEntry, error) {
|
||||
if fileID == "" || localPath == "" {
|
||||
return nil, fmt.Errorf("file id and local path are required")
|
||||
}
|
||||
uploadPath := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID))
|
||||
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
||||
if err != nil {
|
||||
uploadPath = fmt.Sprintf("/api/2.0/files/%s/update.json", url.PathEscape(fileID))
|
||||
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return decodeResponseFileEntry(raw)
|
||||
}
|
||||
|
||||
// FileFolderID returns the parent folder id string for a file entry, if known.
|
||||
func FileFolderID(f *FileEntry) string {
|
||||
if f == nil || f.FolderID == nil {
|
||||
@@ -355,36 +382,15 @@ func FileFolderID(f *FileEntry) string {
|
||||
}
|
||||
|
||||
// DownloadFile streams file bytes from the file's viewUrl using the same auth
|
||||
// as API calls. Writes into dst.
|
||||
// as API calls. Writes into dst. When the portal serves the file from its stale
|
||||
// AWS S3 consumer, the bytes are fetched from the local MinIO store instead
|
||||
// (see storage_fallback.go).
|
||||
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
|
||||
f, err := c.GetFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if f.ViewURL == nil || *f.ViewURL == "" {
|
||||
return 0, fmt.Errorf("file %s has no viewUrl", fileID)
|
||||
}
|
||||
downloadURL := c.resolveAPIURL(*f.ViewURL)
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return 0, fmt.Errorf("GET viewUrl: %d %s", resp.StatusCode, truncate(string(b), 400))
|
||||
}
|
||||
n, err := io.Copy(dst, resp.Body)
|
||||
return n, err
|
||||
return c.downloadFileEntry(ctx, f, dst)
|
||||
}
|
||||
|
||||
func (c *Client) resolveAPIURL(ref string) string {
|
||||
|
||||
+48
-2
@@ -2,6 +2,7 @@ package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -310,25 +311,70 @@ func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// mergeProjectRootForDedupe includes projectFolder files in dedupe scans. OO often lists
|
||||
// root documents only in pf.Files while pf.Folders is empty.
|
||||
func mergeProjectRootForDedupe(rootID string, folders []*FolderEntry, filesByFolder map[string][]*FileEntry, rootFiles []*FileEntry) ([]*FolderEntry, map[string][]*FileEntry) {
|
||||
if rootID == "" {
|
||||
return folders, filesByFolder
|
||||
}
|
||||
if filesByFolder == nil {
|
||||
filesByFolder = map[string][]*FileEntry{}
|
||||
}
|
||||
for _, folder := range folders {
|
||||
if folder != nil && folder.ID != nil && folder.ID.String() == rootID {
|
||||
if len(rootFiles) > 0 {
|
||||
filesByFolder[rootID] = rootFiles
|
||||
}
|
||||
return folders, filesByFolder
|
||||
}
|
||||
}
|
||||
if len(rootFiles) == 0 {
|
||||
return folders, filesByFolder
|
||||
}
|
||||
id := json.Number(rootID)
|
||||
title := "(project root)"
|
||||
folders = append(folders, &FolderEntry{ID: &id, Title: &title})
|
||||
filesByFolder[rootID] = rootFiles
|
||||
return folders, filesByFolder
|
||||
}
|
||||
|
||||
// DedupeProject scans project folders and optionally deletes duplicates.
|
||||
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
|
||||
pf, err := c.GetProjectFiles(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders))
|
||||
rootID, err := c.projectFolderID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var rootFiles []*FileEntry
|
||||
if rootID != "" {
|
||||
rootFiles, err = c.FolderFiles(ctx, rootID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders)+1)
|
||||
folders := make([]*FolderEntry, 0, len(pf.Folders)+1)
|
||||
for _, folder := range pf.Folders {
|
||||
if folder == nil || folder.ID == nil {
|
||||
continue
|
||||
}
|
||||
fid := folder.ID.String()
|
||||
if fid == rootID {
|
||||
filesByFolder[fid] = rootFiles
|
||||
} else {
|
||||
files, err := c.FolderFiles(ctx, fid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
filesByFolder[fid] = files
|
||||
}
|
||||
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts)
|
||||
folders = append(folders, folder)
|
||||
}
|
||||
folders, filesByFolder = mergeProjectRootForDedupe(rootID, folders, filesByFolder, rootFiles)
|
||||
groups := FindProjectDuplicates(folders, filesByFolder, opts)
|
||||
if !apply || len(groups) == 0 {
|
||||
return groups, nil, nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,26 @@ func TestCrossFolderPrefersNonTrash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeProjectRootForDedupe(t *testing.T) {
|
||||
old := &FileEntry{ID: jsonNum("1"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||
newer := &FileEntry{ID: jsonNum("2"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||
rootFiles := []*FileEntry{old, newer}
|
||||
folders, byFolder := mergeProjectRootForDedupe("489", nil, nil, rootFiles)
|
||||
if len(folders) != 1 || folders[0].ID.String() != "489" {
|
||||
t.Fatalf("folders=%+v", folders)
|
||||
}
|
||||
if len(byFolder["489"]) != 2 {
|
||||
t.Fatalf("root files=%d", len(byFolder["489"]))
|
||||
}
|
||||
groups := findWithinFolderDuplicates([]ProjectFolderFile{
|
||||
{FolderID: "489", FolderTitle: "(project root)", File: old},
|
||||
{FolderID: "489", FolderTitle: "(project root)", File: newer},
|
||||
})
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups=%d", len(groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrashFolderTitle(t *testing.T) {
|
||||
if !IsTrashFolderTitle("_trash-md") {
|
||||
t.Fatal("expected trash")
|
||||
|
||||
@@ -3,10 +3,15 @@ package onlyoffice
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrFileExists is returned when --no-replace / no-clobber upload hits an existing stem|ext.
|
||||
var ErrFileExists = errors.New("onlyoffice: file already exists in folder (use replace or delete first)")
|
||||
|
||||
// FileEntryStem returns the logical basename without duplicated extensions.
|
||||
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
|
||||
func FileEntryStem(f *FileEntry) string {
|
||||
@@ -116,6 +121,38 @@ func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) (
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// AssertNoFileConflict reports ErrFileExists when localPath stem|ext is already in folderID.
|
||||
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
|
||||
files, err := c.FolderFiles(ctx, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stem := UploadStemFromLocal(localPath)
|
||||
ext := UploadExtFromLocal(localPath)
|
||||
matches := FindFilesByDedupKey(files, stem, ext)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(matches))
|
||||
for _, f := range matches {
|
||||
ids = append(ids, fmt.Sprintf("%d", FileEntryNumericID(f)))
|
||||
}
|
||||
return fmt.Errorf("%w: %s%s in folder %s (existing file ids: %s)",
|
||||
ErrFileExists, stem, ext, folderID, strings.Join(ids, ", "))
|
||||
}
|
||||
|
||||
// UploadProjectFileNoClobber uploads only when stem|ext is not already in the project folder.
|
||||
func (c *Client) UploadProjectFileNoClobber(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
|
||||
folderID, err := c.projectFolderID(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.UploadProjectFile(ctx, projectID, localPath)
|
||||
}
|
||||
|
||||
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
||||
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
|
||||
stem := UploadStemFromLocal(localPath)
|
||||
|
||||
+63
-29
@@ -144,27 +144,85 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
||||
}
|
||||
|
||||
// MoveDavItems moves the given folders and/or files into destFolderID.
|
||||
// The fileops API answers 200 with per-operation error strings even when
|
||||
// nothing moves (e.g. missing permission), so the response is parsed and the
|
||||
// first operation error is returned instead of a silent nil.
|
||||
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
|
||||
"folderIds": nums(folderIDs),
|
||||
"fileIds": nums(fileIDs),
|
||||
"destFolderId": num(destFolderID),
|
||||
"resolveType": "Skip",
|
||||
"holdResult": true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileopsError(raw)
|
||||
}
|
||||
|
||||
// CopyDavItems copies the given folders and/or files into destFolderID.
|
||||
// Per-operation errors are surfaced like in MoveDavItems.
|
||||
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
|
||||
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
|
||||
"folderIds": nums(folderIDs),
|
||||
"fileIds": nums(fileIDs),
|
||||
"destFolderId": num(destFolderID),
|
||||
"conflictResolveType": "Skip",
|
||||
"deleteAfter": true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileopsError(raw)
|
||||
}
|
||||
|
||||
// ListFileOps returns the currently active file operations
|
||||
// (GET /api/2.0/files/fileops) for status polling.
|
||||
func (c *Client) ListFileOps(ctx context.Context) ([]map[string]any, error) {
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/files/fileops")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 || string(resp) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var ops []map[string]any
|
||||
if err := json.Unmarshal(resp, &ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// fileopsError extracts per-operation "error" strings from a fileops/move or
|
||||
// fileops/copy envelope. A 200 with error entries means nothing moved.
|
||||
func fileopsError(raw json.RawMessage) error {
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ops []struct {
|
||||
Error *string `json:"error"`
|
||||
Finished *bool `json:"finished"`
|
||||
Progress *int `json:"progress"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &ops); err != nil {
|
||||
return nil // not an operations envelope — nothing to report
|
||||
}
|
||||
var errs []string
|
||||
for _, op := range ops {
|
||||
if op.Error != nil && *op.Error != "" {
|
||||
errs = append(errs, *op.Error)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("onlyoffice: fileops: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDavItems deletes the given folders and/or files.
|
||||
@@ -201,34 +259,10 @@ func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, s
|
||||
return env.Response, nil
|
||||
}
|
||||
|
||||
// DownloadDavFile streams the file identified by id to w, returning bytes copied.
|
||||
// DownloadDavFile streams the file identified by id to w, returning bytes
|
||||
// copied. It shares the MinIO stale-S3 fallback with DownloadFile.
|
||||
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||
file, err := c.GetFile(ctx, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if file.ViewURL == nil || *file.ViewURL == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id)
|
||||
}
|
||||
u := c.resolveAPIURL(*file.ViewURL)
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode)
|
||||
}
|
||||
return io.Copy(w, resp.Body)
|
||||
return c.DownloadFile(ctx, id, w)
|
||||
}
|
||||
|
||||
// --- internal helpers -------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/charmbracelet/bubbles v0.18.0
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/glamour v0.8.0
|
||||
@@ -26,6 +27,7 @@ require (
|
||||
github.com/JohannesKaufmann/dom v0.3.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
|
||||
|
||||
@@ -10,6 +10,10 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package onlyoffice
|
||||
|
||||
// High-level mail folder walk for ETL consumers (2dph brain mail-ingest,
|
||||
// cv tools). This is the "integration layer" half of reusing the canonical
|
||||
// client instead of private per-project OOClient copies: the caller gets a
|
||||
// single hydrated stream instead of hand-rolling list → get → download
|
||||
// against the raw API.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MailSyncAttachment is one attachment of a hydrated mail message.
|
||||
type MailSyncAttachment struct {
|
||||
ID string // id accepted by Client.DownloadMailAttachment
|
||||
Name string
|
||||
Size int64
|
||||
Body []byte // non-nil only when MailSyncOptions.FetchBodies is set
|
||||
}
|
||||
|
||||
// MailSyncMessage is a hydrated mail message for sync pipelines.
|
||||
type MailSyncMessage struct {
|
||||
ID int64
|
||||
Folder int
|
||||
Subject string
|
||||
From string // raw RFC 5322 header value ("Name" <addr>)
|
||||
Date time.Time
|
||||
IsNew bool
|
||||
HasAttachments bool
|
||||
Attachments []MailSyncAttachment
|
||||
}
|
||||
|
||||
// MailSyncOptions controls FetchMailFolder.
|
||||
type MailSyncOptions struct {
|
||||
Limit int // max messages to hydrate; 0 = whole folder
|
||||
StartIndex int // skip this many messages before collecting
|
||||
FetchBodies bool // eagerly download attachment bytes
|
||||
}
|
||||
|
||||
// FetchMailFolder walks a mail folder page by page and hydrates every
|
||||
// message: list → get → (optionally) download attachments. It is the single
|
||||
// entry point sync pipelines need on top of the mail API.
|
||||
//
|
||||
// Messages are returned in API order (newest first). The folder walk stops
|
||||
// at the first empty or short page.
|
||||
func (c *Client) FetchMailFolder(ctx context.Context, folderID int, opts MailSyncOptions) ([]MailSyncMessage, error) {
|
||||
if folderID <= 0 {
|
||||
folderID = MailFolderInbox
|
||||
}
|
||||
var out []MailSyncMessage
|
||||
skipped := 0
|
||||
for page := 1; ; page++ {
|
||||
batch, err := c.ResponseArray(ctx,
|
||||
mailMessagesPath(MailMessagesFilter{Folder: folderID}, page, mailMessagesPageSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FetchMailFolder: %w", err)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
for _, raw := range batch {
|
||||
if skipped < opts.StartIndex {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
msg, err := c.hydrateMailMessage(ctx, raw, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *msg)
|
||||
if opts.Limit > 0 && len(out) >= opts.Limit {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
if len(batch) < mailMessagesPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hydrateMailMessage converts one raw API message into a MailSyncMessage,
|
||||
// fetching the full record when the list item does not carry the attachment
|
||||
// metadata, and downloading bodies when requested.
|
||||
func (c *Client) hydrateMailMessage(ctx context.Context, m map[string]any, opts MailSyncOptions) (*MailSyncMessage, error) {
|
||||
msg := &MailSyncMessage{
|
||||
ID: Int64FromMap(m, "id"),
|
||||
Folder: int(Int64FromMap(m, "folder")),
|
||||
Subject: stringFromMap(m, "subject"),
|
||||
From: stringFromMap(m, "from"),
|
||||
IsNew: boolFromMap(m, "isNew") == "true",
|
||||
}
|
||||
msg.Date = parseMailTime(stringFromMap(m, "date"))
|
||||
|
||||
atts, _ := m["attachments"].([]any)
|
||||
hasFlag := boolFromMap(m, "hasAttachments") == "true"
|
||||
if hasFlag && len(atts) == 0 {
|
||||
// List items may omit the attachment array; pull the full record.
|
||||
full, err := c.GetMailMessage(ctx, strconv.FormatInt(msg.ID, 10))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FetchMailFolder: hydrate message %d: %w", msg.ID, err)
|
||||
}
|
||||
atts, _ = full["attachments"].([]any)
|
||||
}
|
||||
for _, a := range atts {
|
||||
am, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
att := MailSyncAttachment{
|
||||
ID: mailAttachmentID(am),
|
||||
Name: stringFromMap(am, "fileName"),
|
||||
Size: Int64FromMap(am, "size"),
|
||||
}
|
||||
if att.Name == "" {
|
||||
att.Name = stringFromMap(am, "name")
|
||||
}
|
||||
if att.ID != "" {
|
||||
msg.Attachments = append(msg.Attachments, att)
|
||||
}
|
||||
}
|
||||
msg.HasAttachments = hasFlag || len(msg.Attachments) > 0
|
||||
|
||||
if opts.FetchBodies {
|
||||
for i := range msg.Attachments {
|
||||
body, err := c.DownloadMailAttachment(ctx, msg.Attachments[i].ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FetchMailFolder: message %d attachment %q: %w",
|
||||
msg.ID, msg.Attachments[i].Name, err)
|
||||
}
|
||||
msg.Attachments[i].Body = body
|
||||
}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// mailAttachmentID extracts the download id from an attachment object.
|
||||
// OnlyOffice variants use "id", "fileId" or "attachmentId".
|
||||
func mailAttachmentID(am map[string]any) string {
|
||||
for _, key := range []string{"id", "fileId", "attachmentId"} {
|
||||
switch v := am[key].(type) {
|
||||
case string:
|
||||
if s := v; s != "" {
|
||||
return s
|
||||
}
|
||||
case float64:
|
||||
if n := int64(v); n != 0 {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
case int64:
|
||||
if v != 0 {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseMailTime accepts the OnlyOffice timestamp shapes seen in the wild:
|
||||
// RFC3339 (with any fractional digits) and second-precision local form.
|
||||
func parseMailTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", s); err == nil {
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mailsSyncMock serves a two-page inbox: page 1 has two list items (one
|
||||
// reporting hasAttachments but omitting the attachment array, forcing the
|
||||
// full-record fetch), page 2 is empty. The full record for message 102
|
||||
// carries one attachment whose body is served by download.ashx.
|
||||
func newMailSyncTestServer(t *testing.T, msgsPage1 string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/2.0/authentication.json":
|
||||
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc", Path: "/"})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`))
|
||||
|
||||
case r.URL.Path == "/api/2.0/mail/messages":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Query().Get("page") > "1" {
|
||||
_, _ = w.Write([]byte(`{"response":[]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"response":[` + msgsPage1 + `]}`))
|
||||
|
||||
case r.URL.Path == "/api/2.0/mail/messages/102":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"response":{
|
||||
"id":102,"subject":"Full record","from":"\"A\" <a@b.com>",
|
||||
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
|
||||
"hasAttachments":true,
|
||||
"attachments":[{"id":77,"fileName":"report.pdf","size":3}]}}`))
|
||||
|
||||
case r.URL.Path == "/addons/mail/httphandlers/download.ashx":
|
||||
if r.Header.Get("Cookie") == "" {
|
||||
http.Error(w, "missing cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("PDF!"))
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetchMailFolderHydratesAndDownloads(t *testing.T) {
|
||||
page1 := `
|
||||
{"id":101,"subject":"Plain","from":"x@y.z","date":"2026-08-21T09:00:00Z",
|
||||
"folder":1,"isNew":true,"hasAttachments":false},
|
||||
{"id":102,"subject":"With attachment (list item)","from":"a@b.com",
|
||||
"date":"2026-08-22T10:15:00+02:00","folder":1,"isNew":false,
|
||||
"hasAttachments":true}
|
||||
`
|
||||
srv := newMailSyncTestServer(t, page1)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
msgs, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{FetchBodies: true})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMailFolder: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages, want 2", len(msgs))
|
||||
}
|
||||
|
||||
first := msgs[0]
|
||||
if first.ID != 101 || first.Subject != "Plain" || !first.IsNew {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
if first.Date.IsZero() || first.Date.Year() != 2026 {
|
||||
t.Fatalf("first date = %v", first.Date)
|
||||
}
|
||||
if first.HasAttachments {
|
||||
t.Fatalf("first should have no attachments")
|
||||
}
|
||||
|
||||
second := msgs[1]
|
||||
if !second.HasAttachments || len(second.Attachments) != 1 {
|
||||
t.Fatalf("second attachments = %+v", second.Attachments)
|
||||
}
|
||||
att := second.Attachments[0]
|
||||
if att.ID != "77" || att.Name != "report.pdf" || att.Size != 3 || string(att.Body) != "PDF!" {
|
||||
t.Fatalf("attachment = %+v", att)
|
||||
}
|
||||
if second.Date.Location() == time.UTC && second.Date.Hour() != 8 {
|
||||
t.Fatalf("second date = %v (want +02:00 offset preserved)", second.Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMailFolderLimitAndStartIndex(t *testing.T) {
|
||||
var items []string
|
||||
for i := 1; i <= 5; i++ {
|
||||
items = append(items, `{"id":`+string(rune('0'+i))+`,"subject":"m`+string(rune('0'+i))+`",
|
||||
"from":"x@y.z","date":"2026-08-20T00:00:00Z","folder":1}`)
|
||||
}
|
||||
srv := newMailSyncTestServer(t, strings.Join(items, ","))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
got, err := c.FetchMailFolder(ctx, MailFolderInbox, MailSyncOptions{StartIndex: 1, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMailFolder: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d messages, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != 2 || got[1].ID != 3 {
|
||||
t.Fatalf("ids = %d,%d want 2,3", got[0].ID, got[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailTime(t *testing.T) {
|
||||
fractions := "2026-08-22T10:15:00.1234567+02:00"
|
||||
if parseMailTime(fractions).IsZero() {
|
||||
t.Fatalf("RFC3339 with 7-digit fraction failed: %q", fractions)
|
||||
}
|
||||
if parseMailTime("2026-08-22T10:15:00").IsZero() {
|
||||
t.Fatal("second-precision form failed")
|
||||
}
|
||||
if !parseMailTime("").IsZero() || !parseMailTime("garbage").IsZero() {
|
||||
t.Fatal("unparseable input must yield zero time")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package onlyoffice
|
||||
|
||||
// MinIO download fallback for the portal's stale AWS S3 consumer.
|
||||
//
|
||||
// On the Fibu EDL portal some older Documents files live in S3/MinIO, but the
|
||||
// portal's storage consumer still points at s3.us-east-1.amazonaws.com with
|
||||
// access key "minio". Downloads of those files answer 403 InvalidAccessKeyId.
|
||||
// The bytes are present in the local MinIO store under a deterministic object
|
||||
// key, so the client retries the GET there.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMinioEndpoint = "http://192.168.188.10:9000"
|
||||
defaultMinioBucket = "office"
|
||||
minioRegion = "us-east-1"
|
||||
)
|
||||
|
||||
// minioObjectKey is the fallback object key layout the portal's S3 consumer
|
||||
// writes for Documents files: 00/00/01/files/folder_<folderId>/file_<fileId>/v1/content.pdf.
|
||||
// Prefer minioObjectKeyFromURL: the portal stores all files below its storage
|
||||
// root folder, which is not the API folderId returned by GetFile.
|
||||
func minioObjectKey(fileID, folderID string) string {
|
||||
return "00/00/01/files/folder_" + folderID + "/file_" + fileID + "/v1/content.pdf"
|
||||
}
|
||||
|
||||
// minioObjectKeyFromURL extracts the object key from an S3 download URL. Path
|
||||
// style URLs (bucket as first path segment) have that segment removed; virtual
|
||||
// host style URLs are returned as-is. This is authoritative: the portal signs
|
||||
// the exact key, so no folder-id guessing is needed.
|
||||
func minioObjectKeyFromURL(rawURL, bucket string) (string, bool) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Path == "" {
|
||||
return "", false
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
// Path-style URLs carry the bucket as leading segment; the portal's S3
|
||||
// consumer can emit it twice (serviceurl already includes the bucket), so
|
||||
// strip every leading segment equal to the bucket.
|
||||
for len(segs) > 0 && bucket != "" && segs[0] == bucket {
|
||||
segs = segs[1:]
|
||||
}
|
||||
if len(segs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
for _, s := range segs {
|
||||
if s == "" || s == "." || s == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return strings.Join(segs, "/"), true
|
||||
}
|
||||
|
||||
// isStaleS3Redirect reports whether a download landed on the portal's stale AWS
|
||||
// S3 consumer. Such responses either carry an S3 InvalidAccessKeyId XML body or
|
||||
// point at amazonaws.com with the "minio" access key id in the query.
|
||||
func isStaleS3Redirect(rawURL string, body []byte) bool {
|
||||
if strings.Contains(strings.ToLower(string(body)), "invalidaccesskeyid") {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
if !strings.Contains(host, "amazonaws.com") {
|
||||
return false
|
||||
}
|
||||
q := strings.ToLower(u.RawQuery)
|
||||
return strings.Contains(q, "accesskeyid=minio") || strings.Contains(q, "x-amz-credential=minio")
|
||||
}
|
||||
|
||||
// minioConfig is the runtime configuration for the local MinIO fallback.
|
||||
type minioConfig struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
// loadMinioConfig reads the fallback configuration from the environment.
|
||||
// Secrets are never defaulted; without access/secret keys the fallback is off.
|
||||
func loadMinioConfig() minioConfig {
|
||||
return minioConfig{
|
||||
Endpoint: strings.TrimRight(firstNonEmpty(os.Getenv("MINIO_ENDPOINT"), defaultMinioEndpoint), "/"),
|
||||
Bucket: firstNonEmpty(os.Getenv("MINIO_BUCKET"), defaultMinioBucket),
|
||||
AccessKey: os.Getenv("MINIO_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("MINIO_SECRET_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
// downloadFileEntry downloads f's bytes to dst. It transparently falls back to
|
||||
// the local MinIO store when the portal redirects the download to its stale AWS
|
||||
// S3 consumer.
|
||||
func (c *Client) downloadFileEntry(ctx context.Context, f *FileEntry, dst io.Writer) (int64, error) {
|
||||
if f == nil {
|
||||
return 0, fmt.Errorf("onlyoffice: download: nil file entry")
|
||||
}
|
||||
if f.ViewURL == nil || *f.ViewURL == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: file has no viewUrl")
|
||||
}
|
||||
downloadURL := c.resolveAPIURL(*f.ViewURL)
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
finalURL := downloadURL
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
if isStaleS3Redirect(finalURL, b) {
|
||||
key, ok := minioObjectKeyFromURL(finalURL, loadMinioConfig().Bucket)
|
||||
if !ok {
|
||||
fileID := ""
|
||||
if f.ID != nil {
|
||||
fileID = f.ID.String()
|
||||
}
|
||||
key = minioObjectKey(fileID, FileFolderID(f))
|
||||
}
|
||||
n, merr := c.downloadFromMinio(ctx, key, dst)
|
||||
if merr == nil {
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("GET viewUrl: %d (stale S3) and minio fallback: %w", resp.StatusCode, merr)
|
||||
}
|
||||
return 0, fmt.Errorf("GET viewUrl: %d %s", resp.StatusCode, truncate(string(b), 400))
|
||||
}
|
||||
return io.Copy(dst, resp.Body)
|
||||
}
|
||||
|
||||
// downloadFromMinio streams objectKey from the configured MinIO bucket.
|
||||
func (c *Client) downloadFromMinio(ctx context.Context, objectKey string, dst io.Writer) (int64, error) {
|
||||
if objectKey == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: empty object key")
|
||||
}
|
||||
cfg := loadMinioConfig()
|
||||
if cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: MINIO_ACCESS_KEY/MINIO_SECRET_KEY not set")
|
||||
}
|
||||
base, err := url.Parse(cfg.Endpoint)
|
||||
if err != nil || base.Host == "" {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: bad MINIO_ENDPOINT %q", cfg.Endpoint)
|
||||
}
|
||||
u := *base
|
||||
u.Path = "/" + cfg.Bucket + "/" + objectKey
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := signMinioRequest(ctx, cfg, req); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return 0, fmt.Errorf("onlyoffice: minio fallback: %d %s", resp.StatusCode, truncate(string(b), 300))
|
||||
}
|
||||
return io.Copy(dst, resp.Body)
|
||||
}
|
||||
|
||||
// signMinioRequest signs req with AWS Signature V4 for the S3 service.
|
||||
func signMinioRequest(ctx context.Context, cfg minioConfig, req *http.Request) error {
|
||||
sum := sha256.Sum256(nil)
|
||||
creds := aws.Credentials{AccessKeyID: cfg.AccessKey, SecretAccessKey: cfg.SecretKey}
|
||||
if err := v4.NewSigner().SignHTTP(ctx, creds, req, hex.EncodeToString(sum[:]), "s3", minioRegion, time.Now()); err != nil {
|
||||
return fmt.Errorf("onlyoffice: minio fallback: sign: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//go:build integration
|
||||
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegrationMinioFallback downloads known stale-S3 files through the local
|
||||
// MinIO fallback. Requires ONLYOFFICE_URL/USER/PASS (as all integration tests),
|
||||
// MINIO_ACCESS_KEY/MINIO_SECRET_KEY and MINIO_TEST_FILE_IDS="3785,3859,3666";
|
||||
// skips when any of those are missing.
|
||||
func TestIntegrationMinioFallback(t *testing.T) {
|
||||
if os.Getenv("MINIO_ACCESS_KEY") == "" || os.Getenv("MINIO_SECRET_KEY") == "" {
|
||||
t.Skip("MINIO_ACCESS_KEY/MINIO_SECRET_KEY not set — skipping integration test")
|
||||
}
|
||||
raw := strings.TrimSpace(os.Getenv("MINIO_TEST_FILE_IDS"))
|
||||
if raw == "" {
|
||||
t.Skip("MINIO_TEST_FILE_IDS not set — skipping integration test")
|
||||
}
|
||||
c := liveClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
for _, id := range strings.Split(raw, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
n, err := c.DownloadFile(ctx, id, io.Discard)
|
||||
if err != nil {
|
||||
t.Errorf("DownloadFile(%s): %v", id, err)
|
||||
continue
|
||||
}
|
||||
if n == 0 {
|
||||
t.Errorf("DownloadFile(%s): 0 bytes", id)
|
||||
} else {
|
||||
t.Logf("DownloadFile(%s): %d bytes", id, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMinioObjectKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
fileID string
|
||||
folderID string
|
||||
want string
|
||||
}{
|
||||
{"3785", "652", "00/00/01/files/folder_652/file_3785/v1/content.pdf"},
|
||||
{"1", "2", "00/00/01/files/folder_2/file_1/v1/content.pdf"},
|
||||
{"3666", "4000", "00/00/01/files/folder_4000/file_3666/v1/content.pdf"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := minioObjectKey(tc.fileID, tc.folderID); got != tc.want {
|
||||
t.Errorf("minioObjectKey(%q, %q) = %q, want %q", tc.fileID, tc.folderID, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinioObjectKeyFromURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
bucket string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "path style drops bucket segment",
|
||||
url: "https://s3.us-east-1.amazonaws.com/office/00/00/01/files/folder_4000/file_3785/v1/content.pdf?AWSAccessKeyId=minio",
|
||||
bucket: "office",
|
||||
want: "00/00/01/files/folder_4000/file_3785/v1/content.pdf",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "doubled bucket segment (portal serviceurl includes bucket)",
|
||||
url: "https://s3.us-east-1.amazonaws.com/office/office/00/00/01/files/folder_4000/file_3785/v1/content.pdf?AWSAccessKeyId=minio",
|
||||
bucket: "office",
|
||||
want: "00/00/01/files/folder_4000/file_3785/v1/content.pdf",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "virtual host style keeps path",
|
||||
url: "https://office.s3.us-east-1.amazonaws.com/00/00/01/files/folder_4000/file_3785/v1/content.pdf",
|
||||
bucket: "office",
|
||||
want: "00/00/01/files/folder_4000/file_3785/v1/content.pdf",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "foreign first segment kept",
|
||||
url: "https://example.com/other/file_1/v1/content.pdf",
|
||||
bucket: "office",
|
||||
want: "other/file_1/v1/content.pdf",
|
||||
ok: true,
|
||||
},
|
||||
{name: "empty path", url: "https://example.com", bucket: "office", ok: false},
|
||||
{name: "traversal", url: "https://example.com/office/../etc/passwd", bucket: "office", ok: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := minioObjectKeyFromURL(tc.url, tc.bucket)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Errorf("minioObjectKeyFromURL(%q, %q) = (%q, %v), want (%q, %v)", tc.url, tc.bucket, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStaleS3Redirect(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "aws redirect with minio access key",
|
||||
url: "https://s3.us-east-1.amazonaws.com/office/x/file_1?AWSAccessKeyId=minio&Expires=1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "aws redirect with minio x-amz-credential",
|
||||
url: "https://office.s3.us-east-1.amazonaws.com/00/00/01/files/folder_1/file_1?X-Amz-Credential=minio%2F20260914",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid access key xml body",
|
||||
url: "https://portal.internal/download/1",
|
||||
body: []byte(`<?xml version="1.0"?><Error><Code>InvalidAccessKeyId</Code><AWSAccessKeyId>minio</AWSAccessKeyId></Error>`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "regular pdf from portal",
|
||||
url: "https://portal.internal/download/1",
|
||||
body: []byte("%PDF-1.7 data"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "aws redirect with foreign key",
|
||||
url: "https://s3.us-east-1.amazonaws.com/office/x?AWSAccessKeyId=other",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "amazonaws in path but foreign host",
|
||||
url: "https://example.com/amazonaws.com/file?AWSAccessKeyId=minio",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isStaleS3Redirect(tc.url, tc.body); got != tc.want {
|
||||
t.Errorf("isStaleS3Redirect(%q, %q) = %v, want %v", tc.url, tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const staleS3Body = `<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<Error><Code>InvalidAccessKeyId</Code>` +
|
||||
`<Message>The AWS Access Key Id you provided does not exist in our records.</Message>` +
|
||||
`<AWSAccessKeyId>minio</AWSAccessKeyId></Error>`
|
||||
|
||||
func TestDownloadFileMinioFallback(t *testing.T) {
|
||||
const payload = "PDFDATA-3785"
|
||||
|
||||
portal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/2.0/files/file/3785.json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"response":{"id":3785,"title":"04.pdf","folderId":655,"viewUrl":"/download/3785"}}`)
|
||||
case "/download/3785":
|
||||
http.Redirect(w, r, "/office/00/00/01/files/folder_4000/file_3785/v1/content.pdf?AWSAccessKeyId=minio", http.StatusTemporaryRedirect)
|
||||
case "/office/00/00/01/files/folder_4000/file_3785/v1/content.pdf":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, staleS3Body)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer portal.Close()
|
||||
|
||||
var minioPath, minioAuth string
|
||||
minio := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
minioPath, minioAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||
io.WriteString(w, payload)
|
||||
}))
|
||||
defer minio.Close()
|
||||
|
||||
t.Setenv("MINIO_ENDPOINT", minio.URL)
|
||||
t.Setenv("MINIO_BUCKET", "office")
|
||||
t.Setenv("MINIO_ACCESS_KEY", "testkey")
|
||||
t.Setenv("MINIO_SECRET_KEY", "testsecret")
|
||||
|
||||
c := &Client{
|
||||
client: portal.Client(),
|
||||
credentials: &Credentials{Url: portal.URL},
|
||||
token: &Token{Value: "Bearer test", Expires: Time(time.Now().Add(time.Hour))},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
n, err := c.DownloadFile(context.Background(), "3785", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile: %v", err)
|
||||
}
|
||||
if n != int64(len(payload)) || buf.String() != payload {
|
||||
t.Fatalf("got %d bytes %q, want %d bytes %q", n, buf.String(), len(payload), payload)
|
||||
}
|
||||
if want := "/office/00/00/01/files/folder_4000/file_3785/v1/content.pdf"; minioPath != want {
|
||||
t.Errorf("minio path = %q, want %q", minioPath, want)
|
||||
}
|
||||
if !strings.HasPrefix(minioAuth, "AWS4-HMAC-SHA256") {
|
||||
t.Errorf("minio request not SigV4-signed; Authorization=%q", minioAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileMinioFallbackWithoutCreds(t *testing.T) {
|
||||
portal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/2.0/files/file/3785.json":
|
||||
io.WriteString(w, `{"response":{"id":3785,"folderId":652,"viewUrl":"/download/3785"}}`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, staleS3Body)
|
||||
}
|
||||
}))
|
||||
defer portal.Close()
|
||||
|
||||
t.Setenv("MINIO_ACCESS_KEY", "")
|
||||
t.Setenv("MINIO_SECRET_KEY", "")
|
||||
|
||||
c := &Client{
|
||||
client: portal.Client(),
|
||||
credentials: &Credentials{Url: portal.URL},
|
||||
token: &Token{Value: "Bearer test", Expires: Time(time.Now().Add(time.Hour))},
|
||||
}
|
||||
|
||||
_, err := c.DownloadFile(context.Background(), "3785", io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected error without minio credentials")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MINIO_ACCESS_KEY/MINIO_SECRET_KEY not set") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user