From 58b905cc49a0a67c4d2c79b227a5adc6d5a29e21 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Tue, 22 Sep 2026 20:32:26 +0100 Subject: [PATCH] feat(oo): native document conversion (docs pdf/presigned) - lib: PresignedURI, SignJWT (HS256, stdlib), ConvertDocument (DocumentServer /converter; legacy /ConvertService.ashx), DownloadURLTo - oo docs pdf FILE_ID|PATH...: OO file ids or local files (temp upload to --folder, convert, download, cleanup) -> PDF/other - oo docs presigned FILE_ID - docs base from $ONLYOFFICE_DOCS_URL else $ONLYOFFICE_URL/ds-vpath; JWT secret from $ONLYOFFICE_DS_SECRET (DocumentServer CoAuthoring secret) - test: SignJWT --- cmd/oo/docs.go | 154 +++++++++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 2 +- convert.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++++ convert_test.go | 24 +++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 convert.go create mode 100644 convert_test.go diff --git a/cmd/oo/docs.go b/cmd/oo/docs.go index 7e7ff31..eed7aee 100644 --- a/cmd/oo/docs.go +++ b/cmd/oo/docs.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" + "time" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/internal/docpipe" @@ -32,6 +34,8 @@ OCR a scan locally: oo docs ocr scan.pdf --md out.md Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`, } cmd.AddCommand(docsConvertCmd()) + cmd.AddCommand(docsPDFCmd()) + cmd.AddCommand(docsPresignedCmd()) cmd.AddCommand(docsOptimizeCmd()) cmd.AddCommand(docsOCRCmd()) cmd.AddCommand(docsHOCRCmd()) @@ -61,6 +65,156 @@ func docsToolsCmd() *cobra.Command { } } +func docsPDFCmd() *cobra.Command { + var out, docsURL, secret, output, folder string + cmd := &cobra.Command{ + Use: "pdf FILE_ID | PATH [ARG...]", + Short: "Convert files to PDF via the DocumentServer converter (OO file ids or local paths)", + Long: `Native OnlyOffice conversion (the engine behind the portal's "Download as PDF"): + + 1. GET /api/2.0/files/file/{id}/presigneduri → fetchable source URL + 2. POST /converter with a JWT → converted file URL + 3. download the result + +Arguments may be OnlyOffice file ids OR local file paths. A local path is +uploaded to the scratch folder (--folder, default 2 = "My documents"), converted, +downloaded and then removed — so any local document yields a PDF on the fly. + +Docs base defaults to $ONLYOFFICE_DOCS_URL, else $ONLYOFFICE_URL + "/ds-vpath" +(the portal nginx proxies /ds-vpath to the DocumentServer). The JWT secret is +$ONLYOFFICE_DS_SECRET (DocumentServer services.CoAuthoring.secret).`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + base := docsBaseURL(docsURL) + if base == "" { + return fmt.Errorf("docs base url unknown; set --docs-url or ONLYOFFICE_DOCS_URL") + } + sec := secret + if sec == "" { + sec = firstEnv("ONLYOFFICE_DS_SECRET", "OO_DS_SECRET") + } + if sec == "" { + return fmt.Errorf("JWT secret required: --secret or ONLYOFFICE_DS_SECRET") + } + ot := output + if ot == "" { + ot = "pdf" + } + for _, arg := range args { + id, local := arg, false + title := "" + if fi, statErr := os.Stat(arg); statErr == nil && !fi.IsDir() { + // Local file → temporary upload into the scratch folder. + ent, uerr := c.UploadToFolder(cmd.Context(), folder, arg) + if uerr != nil { + return fmt.Errorf("upload %s: %w", arg, uerr) + } + id = strconv.FormatInt(onlyoffice.FileEntryNumericID(ent), 10) + local = true + title = filepath.Base(arg) + } else { + if f, ferr := c.GetFile(cmd.Context(), id); ferr == nil && f != nil && f.Title != nil { + title = *f.Title + } + } + src, err := c.PresignedURI(cmd.Context(), id) + if err != nil { + return fmt.Errorf("presigneduri %s: %w", id, err) + } + res, err := c.ConvertDocument(cmd.Context(), base, sec, onlyoffice.ConvertRequest{ + URL: src, + OutputType: ot, + FileType: strings.TrimPrefix(filepath.Ext(title), "."), + Title: title, + Key: fmt.Sprintf("oo-%s-%d", id, time.Now().UnixNano()), + }) + if err != nil { + return fmt.Errorf("convert %s: %w", id, err) + } + dst := out + if dst == "" { + stem := strings.TrimSuffix(title, filepath.Ext(title)) + if stem == "" { + stem = "file-" + id + } + dst = stem + "." + ot + } + f, err := os.Create(dst) + if err != nil { + return err + } + n, derr := c.DownloadURLTo(cmd.Context(), res.FileURL, f) + f.Close() + if local { + // Best-effort cleanup of the temporary upload. + if nid, e := strconv.Atoi(id); e == nil { + _ = c.DeleteFiles(cmd.Context(), []int{nid}) + } + } + if derr != nil { + return fmt.Errorf("download: %w", derr) + } + printObject(map[string]any{"source": arg, "fileid": id, "title": title, "output": dst, "bytes": n, "type": ot}) + } + return nil + }, + } + cmd.Flags().StringVar(&out, "out", "", "output path (default: ./.<format>)") + cmd.Flags().StringVar(&output, "to", "pdf", "output format (pdf, docx, xlsx, …)") + cmd.Flags().StringVar(&docsURL, "docs-url", "", "DocumentServer base (default $ONLYOFFICE_DOCS_URL or $ONLYOFFICE_URL/ds-vpath)") + cmd.Flags().StringVar(&secret, "secret", "", "JWT secret (default $ONLYOFFICE_DS_SECRET)") + cmd.Flags().StringVar(&folder, "folder", "2", "scratch folder id for local-file uploads") + return cmd +} + +func docsPresignedCmd() *cobra.Command { + return &cobra.Command{ + Use: "presigned FILE_ID", + Short: "Print a short-lived fetchable URI for a portal file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + u, err := c.PresignedURI(cmd.Context(), args[0]) + if err != nil { + return err + } + printObject(map[string]any{"fileid": args[0], "uri": u}) + return nil + }, + } +} + +// docsBaseURL resolves the DocumentServer base: --docs-url, $ONLYOFFICE_DOCS_URL, +// else the portal's /ds-vpath proxy. +func docsBaseURL(flag string) string { + if flag != "" { + return flag + } + if v := os.Getenv("ONLYOFFICE_DOCS_URL"); v != "" { + return v + } + if v := firstEnv("ONLYOFFICE_URL", "ONLYOFFICE_HOST", "OO_URL"); v != "" { + return strings.TrimRight(v, "/") + "/ds-vpath" + } + return "" +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} + func strOrNil(s string) any { if s == "" { return nil diff --git a/cmd/oo/main.go b/cmd/oo/main.go index bdfa068..00b6de1 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -16,7 +16,7 @@ // oo crm cleanup // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | send | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … -// oo docs tools | convert | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx +// oo docs tools | convert | pdf | presigned | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx // oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird // oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] diff --git a/convert.go b/convert.go new file mode 100644 index 0000000..04b953f --- /dev/null +++ b/convert.go @@ -0,0 +1,170 @@ +package onlyoffice + +// Document conversion via the OnlyOffice DocumentServer converter. +// +// The DocumentServer (the same engine behind the portal's "Download as PDF") +// converts any office format. From a portal-reachable host the converter is +// exposed at "<portal>/ds-vpath/converter" (nginx proxy) or directly at +// "http://<docs-server>:8083/converter" (legacy path: /ConvertService.ashx). +// +// Flow: PresignedURI(fileId) → Convert(docsBase, secret, req) → download +// result.FileURL. The JWT is HS256 signed with the DocumentServer's +// services.CoAuthoring.secret (NOT storage.fs.secretString). + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// PresignedURI returns a short-lived, fetchable download URI for a portal file +// (GET /api/2.0/files/file/{fileId}/presigneduri). The DocumentServer can fetch +// it without the caller's session, so it is the input for Convert. +func (c *Client) PresignedURI(ctx context.Context, fileID string) (string, error) { + if fileID == "" { + return "", fmt.Errorf("file id is required") + } + raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/files/file/%s/presigneduri", url.PathEscape(fileID))) + if err != nil { + return "", err + } + resp, err := responseField(raw, "response") + if err != nil { + return "", err + } + var s string + if err := json.Unmarshal(resp, &s); err == nil && s != "" { + return s, nil + } + // Some builds return an object instead of a bare string. + var o map[string]any + if err := json.Unmarshal(resp, &o); err == nil { + for _, k := range []string{"uri", "url", "Uri", "Url"} { + if v, ok := o[k].(string); ok && v != "" { + return v, nil + } + } + } + return "", fmt.Errorf("presigneduri: unexpected response %s", truncate(string(resp), 200)) +} + +// ConvertRequest is the DocumentServer converter body. +type ConvertRequest struct { + URL string `json:"url"` + OutputType string `json:"outputtype"` + FileType string `json:"filetype,omitempty"` + Key string `json:"key"` + Title string `json:"title,omitempty"` +} + +// ConvertResult is the DocumentServer converter reply. +type ConvertResult struct { + FileURL string `json:"fileUrl"` + FileType string `json:"fileType"` + Percent int `json:"percent"` + EndConvert bool `json:"endConvert"` + Error *int `json:"error,omitempty"` +} + +// SignJWT builds an HS256 JWT with the given payload (stdlib only). +func SignJWT(secret string, payload any) (string, error) { + if secret == "" { + return "", fmt.Errorf("jwt secret is empty") + } + hb, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + if err != nil { + return "", err + } + pb, err := json.Marshal(payload) + if err != nil { + return "", err + } + enc := base64.RawURLEncoding.EncodeToString + signing := enc(hb) + "." + enc(pb) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signing)) + return signing + "." + enc(mac.Sum(nil)), nil +} + +// ConvertDocument asks a DocumentServer to convert req.URL into req.OutputType. +// docsBase is e.g. "https://portal/ds-vpath" or "http://localhost:8083"; +// secret is the DocumentServer CoAuthoring JWT secret. Passes the JWT both as +// the AuthorizationJwt header and as a body token. +func (c *Client) ConvertDocument(ctx context.Context, docsBase, secret string, req ConvertRequest) (*ConvertResult, error) { + if strings.TrimSpace(docsBase) == "" { + return nil, fmt.Errorf("docs base url is required") + } + if req.URL == "" { + return nil, fmt.Errorf("source url is required") + } + if req.OutputType == "" { + return nil, fmt.Errorf("outputtype is required") + } + if req.Key == "" { + return nil, fmt.Errorf("conversion key is required") + } + jwt, err := SignJWT(secret, req) + if err != nil { + return nil, err + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + endpoint := strings.TrimRight(docsBase, "/") + "/converter" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("AuthorizationJwt", "Bearer "+jwt) + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("converter request: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("converter: %d %s", resp.StatusCode, truncate(string(raw), 300)) + } + var out ConvertResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("converter decode: %w (%s)", err, truncate(string(raw), 200)) + } + if out.Error != nil { + return &out, fmt.Errorf("converter error %d", *out.Error) + } + if out.FileURL == "" { + return &out, fmt.Errorf("converter returned no fileUrl") + } + return &out, nil +} + +// DownloadURLTo streams an absolute URL (no portal auth) into dst. +func (c *Client) DownloadURLTo(ctx context.Context, rawurl string, dst io.Writer) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawurl, nil) + if err != nil { + return 0, err + } + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("download: %d", resp.StatusCode) + } + return io.Copy(dst, resp.Body) +} diff --git a/convert_test.go b/convert_test.go new file mode 100644 index 0000000..adf56b7 --- /dev/null +++ b/convert_test.go @@ -0,0 +1,24 @@ +package onlyoffice + +import ( + "strings" + "testing" +) + +func TestSignJWT(t *testing.T) { + payload := map[string]any{"url": "u", "outputtype": "pdf"} + tok, err := SignJWT("secret", payload) + if err != nil { + t.Fatal(err) + } + if n := len(strings.Split(tok, ".")); n != 3 { + t.Fatalf("JWT must have 3 parts, got %d", n) + } + tok2, _ := SignJWT("secret", payload) + if tok != tok2 { + t.Fatal("SignJWT must be deterministic for identical input") + } + if _, err := SignJWT("", payload); err == nil { + t.Fatal("expected error for empty secret") + } +}